diff --git a/AGENTS.md b/AGENTS.md index e11ba4624..7a06876cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ Learn more: https://agents.md/ - Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g., `*-v3.md`) rather than overwriting the original file. - Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g., `*-v1.md` when `*-v2.md` exists). +Note on specs repository availability +- The `specs/` repository may be private and is not guaranteed to be present in every clone or environment. Do not add Makefile targets in the main project that depend on `specs/` paths. When `specs/` is available, run its tools directly (e.g., `bash specs/scripts/lint-status.sh`). + ## Project Structure & Languages - Backend: Go (`internal/`, `pkg/`, `cmd/`) + MariaDB/SQLite @@ -191,6 +194,19 @@ Note: Across our public documentation, official images, and in production, the c - Examples assume a Linux/Unix shell. For Windows specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows +### HTTP Download — Security Checklist + +- Use the shared safe HTTP helper instead of ad‑hoc `net/http` code: + - Package: `pkg/service/http/safe` → `safe.Download(destPath, url, *safe.Options)`. + - Default policy in this repo: allow only `http/https`, enforce timeouts and max size, write to a `0600` temp file then rename. +- SSRF protection (mandatory unless explicitly needed for tests): + - Set `AllowPrivate=false` to block private/loopback/multicast/link‑local ranges. + - All redirect targets are validated; the final connected peer IP is also checked. + - Prefer an image‑focused `Accept` header for image downloads: `"image/jpeg, image/png, */*;q=0.1"`. +- Avatars and small images: use the thin wrapper in `internal/thumb/avatar.SafeDownload` which applies stricter defaults (15s timeout, 10 MiB, `AllowPrivate=false`). +- Tests using `httptest.Server` on 127.0.0.1 must pass `AllowPrivate=true` explicitly to succeed. +- Keep per‑resource size budgets small; rely on `io.LimitReader` + `Content-Length` prechecks. + If anything in this file conflicts with the `Makefile` or the Developer Guide, the `Makefile` and the documentation win. When unsure, **ask** for clarification before proceeding. ## Agent Quick Tips (Do This) diff --git a/CODEMAP.md b/CODEMAP.md index 990cf2726..edc949658 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -153,6 +153,12 @@ Security & Hot Spots (Where to Look) - Pipeline: `internal/thumb/vips.go` (VipsInit, VipsRotate, export params). - Sizes & names: `internal/thumb/sizes.go`, `internal/thumb/names.go`, `internal/thumb/filter.go`. +- Safe HTTP downloader: + - Shared utility: `pkg/service/http/safe` (`Download`, `Options`). + - Protections: scheme allow‑list (http/https), pre‑DNS + per‑redirect hostname/IP validation, final peer IP check, size and timeout enforcement, temp file `0600` + rename. + - Avatars: wrapper `internal/thumb/avatar.SafeDownload` applies stricter defaults (15s, 10 MiB, `AllowPrivate=false`, image‑focused `Accept`). + - Tests: `go test ./pkg/service/http/safe -count=1` (includes redirect SSRF cases); avatars: `go test ./internal/thumb/avatar -count=1`. + Performance & Limits - Prefer existing caches/workers/batching as per Makefile and code. - When adding list endpoints, default `count=100` (max `1000`); set `Cache-Control: no-store` for secrets. diff --git a/Makefile b/Makefile index 7bdb3e518..bd65eb185 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,8 @@ swag: swag-json swag-json: @echo "Generating ./internal/api/swagger.json..." swag init --ot json --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api + @echo "Fixing unstable time.Duration enums in swagger.json..." + @GO111MODULE=on go run scripts/tools/swaggerfix/main.go internal/api/swagger.json || { echo "swaggerfix failed"; exit 1; } swag-yaml: @echo "Generating ./internal/api/swagger.yaml..." swag init --ot yaml --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api diff --git a/internal/api/api_client_config.go b/internal/api/api_client_config.go index 2ec3e6347..ee9968bb6 100644 --- a/internal/api/api_client_config.go +++ b/internal/api/api_client_config.go @@ -16,7 +16,13 @@ func UpdateClientConfig() { // GetClientConfig returns the client configuration values as JSON. // -// GET /api/v1/config +// @Summary get client configuration +// @Id GetClientConfig +// @Tags Config +// @Produce json +// @Success 200 {object} gin.H +// @Failure 401 {object} i18n.Response +// @Router /api/v1/config [get] func GetClientConfig(router *gin.RouterGroup) { router.GET("/config", func(c *gin.Context) { sess := Session(ClientIP(c), AuthToken(c)) diff --git a/internal/api/batch_photos.go b/internal/api/batch_photos.go index 332bb808e..7b0483391 100644 --- a/internal/api/batch_photos.go +++ b/internal/api/batch_photos.go @@ -176,7 +176,7 @@ func BatchPhotosRestore(router *gin.RouterGroup) { // @Param photos body form.Selection true "Photo Selection" // @Router /api/v1/batch/photos/approve [post] func BatchPhotosApprove(router *gin.RouterGroup) { - router.POST("batch/photos/approve", func(c *gin.Context) { + router.POST("/batch/photos/approve", func(c *gin.Context) { s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate) if s.Abort(c) { diff --git a/internal/api/connect.go b/internal/api/connect.go index 08d88289a..434c3f2d7 100644 --- a/internal/api/connect.go +++ b/internal/api/connect.go @@ -15,7 +15,16 @@ import ( // Connect confirms external service accounts using a token. // -// PUT /api/v1/connect/:name +// @Summary confirm external service accounts using a token +// @Id ConnectService +// @Tags Config +// @Accept json +// @Produce json +// @Param name path string true "service name (e.g., hub)" +// @Param connect body form.Connect true "connection token" +// @Success 200 {object} gin.H +// @Failure 400,401,403 {object} i18n.Response +// @Router /api/v1/connect/{name} [put] func Connect(router *gin.RouterGroup) { router.PUT("/connect/:name", func(c *gin.Context) { name := clean.ID(c.Param("name")) diff --git a/internal/api/doc_overrides.go b/internal/api/doc_overrides.go new file mode 100644 index 000000000..2a87c60a3 --- /dev/null +++ b/internal/api/doc_overrides.go @@ -0,0 +1,13 @@ +package api + +import "time" + +// Schema Overrides for Swagger generation. + +// Override the generated schema for time.Duration to avoid unstable enums +// from the standard library constants (Nanosecond, Minute, etc.). Using +// a simple integer schema is accurate (nanoseconds) and deterministic. +// +// @name time.Duration +// @description Duration in nanoseconds (int64). Examples: 1000000000 (1s), 60000000000 (1m). +type SwaggerTimeDuration = time.Duration diff --git a/internal/api/folders_search.go b/internal/api/folders_search.go index 2157cb3bd..c3c80644b 100644 --- a/internal/api/folders_search.go +++ b/internal/api/folders_search.go @@ -28,7 +28,13 @@ type FoldersResponse struct { // SearchFoldersOriginals returns folders in originals as JSON. // -// GET /api/v1/folders/originals +// @Summary list folders in originals +// @Id SearchFoldersOriginals +// @Tags Folders +// @Produce json +// @Success 200 {object} api.FoldersResponse +// @Failure 401,403 {object} i18n.Response +// @Router /api/v1/folders/originals [get] func SearchFoldersOriginals(router *gin.RouterGroup) { conf := get.Config() SearchFolders(router, "originals", entity.RootOriginals, conf.OriginalsPath()) @@ -36,7 +42,13 @@ func SearchFoldersOriginals(router *gin.RouterGroup) { // SearchFoldersImport returns import folders as JSON. // -// GET /api/v1/folders/import +// @Summary list folders in import +// @Id SearchFoldersImport +// @Tags Folders +// @Produce json +// @Success 200 {object} api.FoldersResponse +// @Failure 401,403 {object} i18n.Response +// @Router /api/v1/folders/import [get] func SearchFoldersImport(router *gin.RouterGroup) { conf := get.Config() SearchFolders(router, "import", entity.RootImport, conf.ImportPath()) diff --git a/internal/api/links.go b/internal/api/links.go index 82eca8f81..8407f8371 100644 --- a/internal/api/links.go +++ b/internal/api/links.go @@ -87,10 +87,10 @@ func DeleteLink(c *gin.Context) { c.JSON(http.StatusOK, link) } -// CreateLink adds a new share link and return it as JSON. -// -// @Tags Links -// @Router /api/v1/{entity}/{uid}/links [post] +// CreateLink adds a new share link and returns it as JSON. +// Note: Internal helper used by resource-specific endpoints (e.g., albums, photos). +// Swagger annotations are defined on those public handlers to avoid generating +// undocumented generic paths like "/api/v1/{entity}/{uid}/links". func CreateLink(c *gin.Context) { s := Auth(c, acl.ResourceShares, acl.ActionCreate) diff --git a/internal/api/oauth_token_ratelimit_test.go b/internal/api/oauth_token_ratelimit_test.go new file mode 100644 index 000000000..e789cb3cf --- /dev/null +++ b/internal/api/oauth_token_ratelimit_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" + "golang.org/x/time/rate" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/server/limiter" + "github.com/photoprism/photoprism/pkg/authn" + "github.com/photoprism/photoprism/pkg/service/http/header" +) + +func TestOAuthToken_RateLimit_ClientCredentials(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + OAuthToken(router) + + // Tighten rate limits + oldLogin, oldAuth := limiter.Login, limiter.Auth + defer func() { limiter.Login, limiter.Auth = oldLogin, oldAuth }() + limiter.Login = limiter.NewLimit(rate.Every(24*time.Hour), 3) // burst 3 + limiter.Auth = limiter.NewLimit(rate.Every(24*time.Hour), 3) + + // Invalid client secret repeatedly (from UnknownIP: no headers set) + path := "/api/v1/oauth/token" + for i := 0; i < 3; i++ { + data := url.Values{ + "grant_type": {authn.GrantClientCredentials.String()}, + "client_id": {"cs5cpu17n6gj2qo5"}, + "client_secret": {"INVALID"}, + "scope": {"metrics"}, + } + req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + } + // Next call should be rate limited + data := url.Values{ + "grant_type": {authn.GrantClientCredentials.String()}, + "client_id": {"cs5cpu17n6gj2qo5"}, + "client_secret": {"INVALID"}, + "scope": {"metrics"}, + } + req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + req.Header.Set("X-Forwarded-For", "198.51.100.99") + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusTooManyRequests, w.Code) +} + +func TestOAuthToken_ResponseFields_ClientSuccess(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + OAuthToken(router) + + data := url.Values{ + "grant_type": {authn.GrantClientCredentials.String()}, + "client_id": {"cs5cpu17n6gj2qo5"}, + "client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"}, + "scope": {"metrics"}, + } + req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.NotEmpty(t, gjson.Get(body, "access_token").String()) + tokType := gjson.Get(body, "token_type").String() + assert.True(t, strings.EqualFold(tokType, "bearer")) + assert.GreaterOrEqual(t, gjson.Get(body, "expires_in").Int(), int64(0)) + assert.Equal(t, "metrics", gjson.Get(body, "scope").String()) +} + +func TestOAuthToken_ResponseFields_UserSuccess(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + sess := AuthenticateUser(app, router, "alice", "Alice123!") + OAuthToken(router) + + data := url.Values{ + "grant_type": {authn.GrantPassword.String()}, + "client_name": {"TestApp"}, + "username": {"alice"}, + "password": {"Alice123!"}, + "scope": {"*"}, + } + req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + req.Header.Set(header.XAuthToken, sess) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.NotEmpty(t, gjson.Get(body, "access_token").String()) + tokType := gjson.Get(body, "token_type").String() + assert.True(t, strings.EqualFold(tokType, "bearer")) + assert.GreaterOrEqual(t, gjson.Get(body, "expires_in").Int(), int64(0)) + assert.Equal(t, "*", gjson.Get(body, "scope").String()) +} + +func TestOAuthToken_BadRequestsAndErrors(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + OAuthToken(router) + + // Missing grant_type & creds -> invalid credentials + req, _ := http.NewRequest(http.MethodPost, "/api/v1/oauth/token", nil) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Unknown grant type + data := url.Values{ + "grant_type": {"unknown"}, + } + req, _ = http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + w = httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + // Password grant with wrong password + sess := AuthenticateUser(app, router, "alice", "Alice123!") + data = url.Values{ + "grant_type": {authn.GrantPassword.String()}, + "client_name": {"AppPasswordAlice"}, + "username": {"alice"}, + "password": {"WrongPassword!"}, + "scope": {"*"}, + } + req, _ = http.NewRequest(http.MethodPost, "/api/v1/oauth/token", strings.NewReader(data.Encode())) + req.Header.Set(header.ContentType, header.ContentTypeForm) + req.Header.Set(header.XAuthToken, sess) + w = httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/internal/api/session_ratelimit_test.go b/internal/api/session_ratelimit_test.go new file mode 100644 index 000000000..70bad0af4 --- /dev/null +++ b/internal/api/session_ratelimit_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/server/limiter" +) + +func TestCreateSession_RateLimitExceeded(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + CreateSession(router) + + // Tighten rate limits and do repeated bad logins from UnknownIP + oldLogin, oldAuth := limiter.Login, limiter.Auth + defer func() { limiter.Login, limiter.Auth = oldLogin, oldAuth }() + limiter.Login = limiter.NewLimit(rate.Every(24*time.Hour), 3) + limiter.Auth = limiter.NewLimit(rate.Every(24*time.Hour), 3) + + for i := 0; i < 3; i++ { + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "wrong"}`) + assert.Equal(t, http.StatusUnauthorized, r.Code) + } + // Next attempt should be 429 + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "wrong"}`) + assert.Equal(t, http.StatusTooManyRequests, r.Code) +} + +func TestCreateSession_MissingFields(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + CreateSession(router) + // Empty object -> unauthorized (invalid credentials) + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{}`) + assert.Equal(t, http.StatusUnauthorized, r.Code) +} diff --git a/internal/api/swagger.json b/internal/api/swagger.json index f84819ffc..bee51b1c3 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -1,7280 +1,32 @@ { - "swagger": "2.0", - "info": { - "description": "API request bodies and responses are usually JSON-encoded, except for binary data and some of the OAuth2 endpoints. Note that the `Content-Type` header must be set to `application/json` for this, as the request may otherwise fail with error 400.\nWhen clients have a valid access token, e.g. obtained through the `POST /api/v1/session` or `POST /api/v1/oauth/token` endpoint, they can use a standard Bearer Authorization header to authenticate their requests. Submitting the access token with a custom `X-Auth-Token` header is supported as well.", - "title": "PhotoPrism API", - "contact": {}, - "version": "v1" - }, - "host": "demo.photoprism.app", - "paths": { - "/api/v1/albums": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "finds albums and returns them as JSON", - "operationId": "SearchAlbums", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "search result offset", - "name": "offset", - "in": "query" - }, - { - "enum": [ - "favorites", - "name", - "title", - "added", - "edited" - ], - "type": "string", - "description": "sort order", - "name": "order", - "in": "query" - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/search.Album" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "creates a new album", - "operationId": "CreateAlbum", - "parameters": [ - { - "description": "properties of the album to be created (currently supports Title and Favorite)", - "name": "album", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Album" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Album" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "returns album details as JSON", - "operationId": "GetAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Album" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "updates album metadata like title and description", - "operationId": "UpdateAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "properties to be updated", - "name": "album", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Album" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Album" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "deletes an existing album", - "operationId": "DeleteAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/clone": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "creates a new album containing pictures from other albums", - "operationId": "CloneAlbums", - "parameters": [ - { - "description": "Album Selection", - "name": "albums", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - }, - { - "type": "string", - "description": "UID of the album to which the pictures are to be added", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/dl": { - "get": { - "produces": [ - "application/zip" - ], - "tags": [ - "Albums", - "Download" - ], - "summary": "streams the album contents as zip archive", - "operationId": "DownloadAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/like": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "sets the favorite flag for an album", - "operationId": "LikeAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "removes the favorite flag from an album", - "operationId": "DislikeAlbum", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/links": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Links", - "Albums" - ], - "summary": "returns all share links for the given UID as JSON", - "operationId": "GetAlbumLinks", - "parameters": [ - { - "type": "string", - "description": "album uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Link" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Links", - "Albums" - ], - "summary": "adds a new album share link and return it as JSON", - "operationId": "CreateAlbumLink", - "parameters": [ - { - "type": "string", - "description": "album uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "link properties (currently supported: slug, expires)", - "name": "link", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Link" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Link" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/links/{linkuid}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Links", - "Albums" - ], - "summary": "updates an album share link and return it as JSON", - "operationId": "UpdateAlbumLink", - "parameters": [ - { - "type": "string", - "description": "album uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "link uid", - "name": "linkuid", - "in": "path", - "required": true - }, - { - "description": "properties to be updated (currently supported: slug, expires, token)", - "name": "link", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Link" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Link" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Links", - "Albums" - ], - "summary": "deletes an album share link", - "operationId": "DeleteAlbumLink", - "parameters": [ - { - "type": "string", - "description": "album", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "link uid", - "name": "linkuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Link" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/photos": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "adds photos to an album", - "operationId": "AddPhotosToAlbum", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - }, - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "removes photos from an album", - "operationId": "RemovePhotosFromAlbum", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - }, - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/albums/{uid}/t/{token}/{size}": { - "get": { - "produces": [ - "image/jpeg", - "image/svg+xml" - ], - "tags": [ - "Images", - "Albums" - ], - "summary": "returns an album cover image", - "operationId": "AlbumCover", - "parameters": [ - { - "type": "string", - "description": "Album UID", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", - "name": "token", - "in": "path", - "required": true - }, - { - "enum": [ - "tile_50", - "tile_100", - "left_224", - "right_224", - "tile_224", - "tile_500", - "fit_720", - "tile_1080", - "fit_1280", - "fit_1600", - "fit_1920", - "fit_2048", - "fit_2560", - "fit_3840", - "fit_4096", - "fit_7680" - ], - "type": "string", - "description": "thumbnail size", - "name": "size", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/batch/albums/delete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "permanently removes multiple albums", - "operationId": "BatchAlbumsDelete", - "parameters": [ - { - "description": "Album Selection", - "name": "albums", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/labels/delete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels" - ], - "summary": "deletes multiple labels", - "operationId": "BatchLabelsDelete", - "parameters": [ - { - "description": "Label Selection", - "name": "labels", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/approve": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "approves multiple photos that are currently under review", - "operationId": "BatchPhotosApprove", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/archive": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "moves multiple photos to the archive", - "operationId": "BatchPhotosArchive", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/delete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "permanently removes multiple or all photos from the archive", - "operationId": "BatchPhotosDelete", - "parameters": [ - { - "description": "All or Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/edit": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "returns and updates the metadata of multiple photos", - "operationId": "BatchPhotosEdit", - "parameters": [ - { - "description": "photos selection and values", - "name": "Request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/batch.PhotosRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/batch.PhotosResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/private": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "toggles private state of multiple photos", - "operationId": "BatchPhotosPrivate", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/batch/photos/restore": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "restores multiple photos from the archive", - "operationId": "BatchPhotosRestore", - "parameters": [ - { - "description": "Photo Selection", - "name": "photos", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Selection" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "cluster summary", - "operationId": "ClusterSummary", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/cluster.SummaryResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster/health": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "cluster health", - "operationId": "ClusterHealth", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HealthResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster/nodes": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "lists registered nodes", - "operationId": "ClusterListNodes", - "parameters": [ - { - "maximum": 1000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results (default 100, max 1000)", - "name": "count", - "in": "query" - }, - { - "minimum": 0, - "type": "integer", - "description": "result offset", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/cluster.Node" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster/nodes/register": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "registers a node, provisions DB credentials, and issues nodeSecret", - "operationId": "ClusterNodesRegister", - "parameters": [ - { - "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl, rotateDatabase, rotateSecret)", - "name": "request", - "in": "body", - "required": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/cluster.RegisterResponse" - } - }, - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/cluster.RegisterResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster/nodes/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "get node by id", - "operationId": "ClusterGetNode", - "parameters": [ - { - "type": "string", - "description": "node id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/cluster.Node" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "delete node by id", - "operationId": "ClusterDeleteNode", - "parameters": [ - { - "type": "string", - "description": "node id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/cluster.StatusResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Cluster" - ], - "summary": "update node fields", - "operationId": "ClusterUpdateNode", - "parameters": [ - { - "type": "string", - "description": "node id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "properties to update (role, labels, advertiseUrl, siteUrl)", - "name": "node", - "in": "body", - "required": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/cluster.StatusResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/cluster/theme": { - "get": { - "produces": [ - "application/zip" - ], - "tags": [ - "Cluster" - ], - "summary": "returns custom theme files as zip, if available", - "operationId": "ClusterGetTheme", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/config/options": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Config", - "Settings" - ], - "summary": "returns backend config options", - "operationId": "GetConfigOptions", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/config.Options" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Config", - "Settings" - ], - "summary": "updates backend config options", - "operationId": "SaveConfigOptions", - "parameters": [ - { - "description": "properties to be updated (only submit values that should be changed)", - "name": "options", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/config.Options" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/config.Options" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/dl/{file}": { - "get": { - "produces": [ - "application/octet-stream" - ], - "tags": [ - "Images", - "Files" - ], - "summary": "returns the raw file data", - "operationId": "GetDownload", - "parameters": [ - { - "type": "string", - "description": "file hash or unique download id", - "name": "file", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/echo": { - "get": { - "tags": [ - "Debug" - ], - "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": [ - "application/json" - ], - "tags": [ - "Errors" - ], - "summary": "searches the error logs and returns the results as JSON", - "operationId": "GetErrors", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "search result offset", - "name": "offset", - "in": "query" - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Error" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Errors" - ], - "summary": "removes all entries from the error logs", - "operationId": "DeleteErrors", - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/faces": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Faces" - ], - "summary": "finds and returns faces as JSON", - "operationId": "SearchFaces", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "search result offset", - "name": "offset", - "in": "query" - }, - { - "enum": [ - "subject", - "added", - "samples" - ], - "type": "string", - "description": "sort order", - "name": "order", - "in": "query" - }, - { - "enum": [ - "yes", - "no" - ], - "type": "string", - "description": "show hidden", - "name": "hidden", - "in": "query" - }, - { - "enum": [ - "yes", - "no" - ], - "type": "string", - "description": "show unknown", - "name": "unknown", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/search.Face" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/faces/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Faces" - ], - "summary": "returns a face as JSON", - "operationId": "GetFace", - "parameters": [ - { - "type": "string", - "description": "face id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Face" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Faces" - ], - "summary": "updates face properties", - "operationId": "UpdateFace", - "parameters": [ - { - "type": "string", - "description": "face id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "properties to be updated", - "name": "face", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Face" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Face" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/feedback": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Admin" - ], - "summary": "allows members to submit a feedback message to the PhotoPrism team", - "operationId": "SendFeedback", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/form.Feedback" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/files/{hash}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Files" - ], - "summary": "returns file details as JSON", - "operationId": "GetFile", - "parameters": [ - { - "type": "string", - "description": "hash (string) SHA-1 hash of the file", - "name": "hash", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.File" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/folders/t/{uid}/{token}/{size}": { - "get": { - "produces": [ - "image/jpeg", - "image/svg+xml" - ], - "tags": [ - "Images", - "Folders" - ], - "summary": "returns a folder cover image", - "operationId": "FolderCover", - "parameters": [ - { - "type": "string", - "description": "folder uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", - "name": "token", - "in": "path", - "required": true - }, - { - "enum": [ - "tile_50", - "tile_100", - "left_224", - "right_224", - "tile_224", - "tile_500", - "fit_720", - "tile_1080", - "fit_1280", - "fit_1600", - "fit_1920", - "fit_2048", - "fit_2560", - "fit_3840", - "fit_4096", - "fit_7680" - ], - "type": "string", - "description": "thumbnail size", - "name": "size", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/geo": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "finds photos and returns results as JSON, so they can be displayed on a map or in a viewer", - "operationId": "SearchGeo", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of files", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "file offset", - "name": "offset", - "in": "query" - }, - { - "type": "boolean", - "description": "excludes private pictures", - "name": "public", - "in": "query" - }, - { - "enum": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7 - ], - "type": "integer", - "description": "minimum quality score (1-7)", - "name": "quality", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - }, - { - "type": "string", - "description": "album uid", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "photo path", - "name": "path", - "in": "query" - }, - { - "type": "boolean", - "description": "is type video", - "name": "video", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/search.GeoResult" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/import/": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Library" - ], - "summary": "start import", - "operationId": "StartImport", - "parameters": [ - { - "description": "import options", - "name": "options", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.ImportOptions" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/index": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Library" - ], - "summary": "start indexing", - "operationId": "StartIndexing", - "parameters": [ - { - "description": "index options", - "name": "options", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.IndexOptions" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/labels": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Labels" - ], - "summary": "finds and returns labels as JSON", - "operationId": "SearchLabels", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "search result offset", - "name": "offset", - "in": "query" - }, - { - "type": "boolean", - "description": "show all", - "name": "all", - "in": "query" - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/search.Label" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/labels/{uid}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels" - ], - "summary": "updates label name", - "operationId": "UpdateLabel", - "parameters": [ - { - "type": "string", - "description": "Label UID", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "Label Name", - "name": "label", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Label" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Label" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/labels/{uid}/like": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels" - ], - "summary": "sets favorite flag for a label", - "operationId": "LikeLabel", - "parameters": [ - { - "type": "string", - "description": "Label UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels" - ], - "summary": "removes favorite flag from a label", - "operationId": "DislikeLabel", - "parameters": [ - { - "type": "string", - "description": "Label UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/labels/{uid}/t/{token}/{size}": { - "get": { - "produces": [ - "image/jpeg", - "image/svg+xml" - ], - "tags": [ - "Images", - "Labels" - ], - "summary": "returns a label cover image", - "operationId": "LabelCover", - "parameters": [ - { - "type": "string", - "description": "Label UID", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", - "name": "token", - "in": "path", - "required": true - }, - { - "enum": [ - "tile_50", - "tile_100", - "left_224", - "right_224", - "tile_224", - "tile_500", - "fit_720", - "tile_1080", - "fit_1280", - "fit_1600", - "fit_1920", - "fit_2048", - "fit_2560", - "fit_3840", - "fit_4096", - "fit_7680" - ], - "type": "string", - "description": "thumbnail size", - "name": "size", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/markers": { - "post": { - "tags": [ - "Files" - ], - "responses": {} - } - }, - "/api/v1/markers/{marker_uid}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Files" - ], - "summary": "update a marker (face/subject region)", - "operationId": "UpdateMarker", - "parameters": [ - { - "type": "string", - "description": "marker uid", - "name": "marker_uid", - "in": "path", - "required": true - }, - { - "description": "marker properties", - "name": "marker", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Marker" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Marker" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/markers/{marker_uid}/subject": { - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "Files" - ], - "summary": "clear the subject of a marker", - "operationId": "ClearMarkerSubject", - "parameters": [ - { - "type": "string", - "description": "marker uid", - "name": "marker_uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Marker" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/metrics": { - "get": { - "produces": [ - "text/event-stream" - ], - "tags": [ - "Metrics" - ], - "summary": "a prometheus-compatible metrics endpoint for monitoring this instance", - "operationId": "GetMetrics", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/io_prometheus_client.MetricFamily" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/moments/time": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Albums" - ], - "summary": "returns monthly albums as JSON", - "operationId": "GetMomentsTime", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Album" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/oauth/authorize": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "OAuth2 authorization endpoint (not implemented)", - "operationId": "OAuthAuthorize", - "responses": { - "405": { - "description": "Method Not Allowed", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/oauth/revoke": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "revoke an OAuth2 access token or session", - "operationId": "OAuthRevoke", - "parameters": [ - { - "description": "revoke request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.OAuthRevokeToken" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/oauth/token": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "create an OAuth2 access token", - "operationId": "OAuthToken", - "parameters": [ - { - "description": "token request (supports client_credentials, password, or session grant)", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.OAuthCreateToken" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/oidc/login": { - "get": { - "produces": [ - "text/html" - ], - "tags": [ - "Authentication" - ], - "summary": "start OpenID Connect login (browser redirect)", - "operationId": "OIDCLogin", - "responses": { - "307": { - "description": "redirect to provider login page", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/oidc/redirect": { - "get": { - "produces": [ - "text/html" - ], - "tags": [ - "Authentication" - ], - "summary": "complete OIDC login (callback)", - "operationId": "OIDCRedirect", - "parameters": [ - { - "type": "string", - "description": "opaque OAuth2 state value", - "name": "state", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "authorization code", - "name": "code", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "HTML page bootstrapping token storage", - "schema": { - "type": "string" - } - }, - "401": { - "description": "rendered error page", - "schema": { - "type": "string" - } - }, - "403": { - "description": "rendered error page", - "schema": { - "type": "string" - } - }, - "429": { - "description": "rendered error page", - "schema": { - "type": "string" - } - } - } - } - }, - "/api/v1/photos": { - "get": { - "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/search/#get-apiv1photos", - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "finds pictures and returns them as JSON", - "operationId": "SearchPhotos", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of files", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "file offset", - "name": "offset", - "in": "query" - }, - { - "enum": [ - "name", - "title", - "added", - "edited", - "newest", - "oldest", - "size", - "random", - "duration", - "relevance" - ], - "type": "string", - "description": "sort order", - "name": "order", - "in": "query" - }, - { - "type": "boolean", - "description": "groups consecutive files that belong to the same photo", - "name": "merged", - "in": "query" - }, - { - "type": "boolean", - "description": "excludes private pictures", - "name": "public", - "in": "query" - }, - { - "enum": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7 - ], - "type": "integer", - "description": "minimum quality score (1-7)", - "name": "quality", - "in": "query" - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - }, - { - "type": "string", - "description": "album uid", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "photo path", - "name": "path", - "in": "query" - }, - { - "type": "boolean", - "description": "is type video", - "name": "video", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/search.Photo" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "returns picture details as JSON", - "operationId": "GetPhoto", - "parameters": [ - { - "type": "string", - "description": "Photo UID", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "updates picture details and returns them as JSON", - "operationId": "UpdatePhoto", - "parameters": [ - { - "type": "string", - "description": "Photo UID", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "properties to be updated (only submit values that should be changed)", - "name": "photo", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Photo" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/approve": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "marks a photo in review as approved", - "operationId": "ApprovePhoto", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/dl": { - "get": { - "produces": [ - "application/octet-stream" - ], - "tags": [ - "Images", - "Files" - ], - "summary": "returns the primary file matching that belongs to the photo", - "operationId": "GetPhotoDownload", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/photos/{uid}/files/{fileuid}": { - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Files" - ], - "summary": "removes a file from storage", - "operationId": "DeleteFile", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "file uid", - "name": "fileuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/files/{fileuid}/orientation": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Files" - ], - "summary": "changes the orientation of a file", - "operationId": "ChangeFileOrientation", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "file uid", - "name": "fileuid", - "in": "path", - "required": true - }, - { - "description": "file orientation", - "name": "file", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.File" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/files/{fileuid}/primary": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos", - "Stacks" - ], - "summary": "sets the primary file for a photo", - "operationId": "PhotoPrimary", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "file uid", - "name": "fileuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/files/{fileuid}/unstack": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos", - "Stacks" - ], - "summary": "removes a file from an existing photo stack", - "operationId": "PhotoUnstack", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "file uid", - "name": "fileuid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/label": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels", - "Photos" - ], - "summary": "adds a label to a photo", - "operationId": "AddPhotoLabel", - "parameters": [ - { - "description": "label properties", - "name": "label", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Label" - } - }, - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/label/{id}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels", - "Photos" - ], - "summary": "changes a photo label", - "operationId": "UpdatePhotoLabel", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "label id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "properties to be updated (currently supports: uncertainty)", - "name": "label", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Label" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Labels", - "Photos" - ], - "summary": "removes a label from a photo", - "operationId": "RemovePhotoLabel", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "label id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Photo" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/like": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "flags a photo as favorite", - "operationId": "LikePhoto", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Photos" - ], - "summary": "removes the favorite flags from a photo", - "operationId": "DislikePhoto", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/photos/{uid}/yaml": { - "get": { - "produces": [ - "text/x-yaml" - ], - "tags": [ - "Photos" - ], - "summary": "returns picture details as YAML", - "operationId": "GetPhotoYaml", - "parameters": [ - { - "type": "string", - "description": "photo uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/places/reverse": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Places" - ], - "summary": "returns location details for the specified coordinates", - "operationId": "GetPlacesReverse", - "parameters": [ - { - "type": "string", - "description": "Latitude", - "name": "lat", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Longitude", - "name": "lng", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Locale", - "name": "locale", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/places.Location" - } - }, - "400": { - "description": "Missing latitude or longitude", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Geocoding service error", - "schema": { - "$ref": "#/definitions/gin.H" - } - } - } - } - }, - "/api/v1/places/search": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Places" - ], - "summary": "returns locations that match the specified search query", - "operationId": "GetPlacesSearch", - "parameters": [ - { - "type": "string", - "description": "Search query", - "name": "q", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Locale for results (default: en)", - "name": "locale", - "in": "query" - }, - { - "type": "integer", - "description": "Maximum number of results (default: 10, max: 50)", - "name": "count", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/places.SearchResult" - } - } - }, - "400": { - "description": "Missing search query", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Search service error", - "schema": { - "$ref": "#/definitions/gin.H" - } - } - } - } - }, - "/api/v1/server/stop": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Admin" - ], - "summary": "allows authorized admins to restart the server", - "operationId": "StopServer", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/config.Options" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/services": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "finds services and returns them as JSON", - "operationId": "SearchServices", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/entity.Service" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "creates a new remote service account configuration", - "operationId": "AddService", - "parameters": [ - { - "description": "properties of the service to be created", - "name": "service", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Service" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Service" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/services/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "returns the specified remote service account configuration as JSON", - "operationId": "GetService", - "parameters": [ - { - "type": "string", - "description": "service id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Service" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "updates a remote account configuration", - "operationId": "UpdateService", - "parameters": [ - { - "type": "string", - "description": "service id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "properties to be updated (only submit values that should be changed)", - "name": "service", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Service" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Service" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "removes a remote service account configuration", - "operationId": "DeleteService", - "parameters": [ - { - "type": "string", - "description": "service id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Service" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/services/{id}/folders": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "returns folders that belong to a remote service account", - "operationId": "GetServiceFolders", - "parameters": [ - { - "type": "string", - "description": "service id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "object" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/services/{id}/upload": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Services" - ], - "summary": "uploads files to the selected service account", - "operationId": "UploadToService", - "parameters": [ - { - "type": "string", - "description": "service id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/entity.File" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/session": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "get the current session or a session by id", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "create a session (login)", - "parameters": [ - { - "description": "login credentials", - "name": "credentials", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Login" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "delete a session (logout)", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/session/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "get the current session or a session by id", - "parameters": [ - { - "type": "string", - "description": "session id", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "delete a session (logout)", - "parameters": [ - { - "type": "string", - "description": "session id or ref id", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/sessions": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "create a session (login)", - "parameters": [ - { - "description": "login credentials", - "name": "credentials", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Login" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/sessions/{id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "get the current session or a session by id", - "parameters": [ - { - "type": "string", - "description": "session id", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "Authentication" - ], - "summary": "delete a session (logout)", - "parameters": [ - { - "type": "string", - "description": "session id or ref id", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/settings": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Settings" - ], - "summary": "returns the user app settings as JSON", - "operationId": "GetSettings", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/customize.Settings" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Settings" - ], - "summary": "saves the user app settings", - "operationId": "SaveSettings", - "parameters": [ - { - "description": "user settings", - "name": "settings", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/customize.Settings" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/customize.Settings" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/status": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Debug" - ], - "summary": "responds with status code 200 if the server is operational", - "operationId": "GetStatus", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/gin.H" - } - } - } - } - }, - "/api/v1/subjects": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subjects" - ], - "summary": "finds and returns subjects as JSON", - "operationId": "SearchSubjects", - "parameters": [ - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "maximum": 100000, - "minimum": 0, - "type": "integer", - "description": "search result offset", - "name": "offset", - "in": "query" - }, - { - "enum": [ - "name", - "count", - "added", - "relevance" - ], - "type": "string", - "description": "sort order", - "name": "order", - "in": "query" - }, - { - "enum": [ - "yes", - "no" - ], - "type": "string", - "description": "show hidden", - "name": "hidden", - "in": "query" - }, - { - "type": "integer", - "description": "minimum number of files", - "name": "files", - "in": "query" - }, - { - "type": "string", - "description": "search query", - "name": "q", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/search.Subject" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/subjects/{uid}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Subjects" - ], - "summary": "returns a subject as JSON", - "operationId": "GetSubject", - "parameters": [ - { - "type": "string", - "description": "subject uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Subject" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Subjects" - ], - "summary": "updates subject properties", - "operationId": "UpdateSubject", - "parameters": [ - { - "type": "string", - "description": "subject uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "properties to be updated (only submit values that should be changed)", - "name": "subject", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Subject" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Subject" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/subjects/{uid}/like": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Subjects" - ], - "summary": "flags a subject as favorite", - "operationId": "LikeSubject", - "parameters": [ - { - "type": "string", - "description": "subject uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Subjects" - ], - "summary": "removes the favorite flag from a subject", - "operationId": "DislikeSubject", - "parameters": [ - { - "type": "string", - "description": "subject uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/t/{thumb}/{token}/{size}": { - "get": { - "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/thumbnails/#image-endpoint-uri", - "produces": [ - "image/jpeg", - " image/svg+xml" - ], - "tags": [ - "Images", - "Files" - ], - "summary": "returns a thumbnail image with the requested size", - "operationId": "GetThumb", - "parameters": [ - { - "type": "string", - "description": "SHA1 file hash, optionally with a crop area suffixed, e.g. '-016014058037'", - "name": "thumb", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", - "name": "token", - "in": "path", - "required": true - }, - { - "enum": [ - "tile_50", - "tile_100", - "left_224", - "right_224", - "tile_224", - "tile_500", - "fit_720", - "tile_1080", - "fit_1280", - "fit_1600", - "fit_1920", - "fit_2048", - "fit_2560", - "fit_3840", - "fit_4096", - "fit_7680" - ], - "type": "string", - "description": "thumbnail size", - "name": "size", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "file" - } - } - } - } - }, - "/api/v1/users/{uid}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "update user profile information", - "operationId": "UpdateUser", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "properties to be updated", - "name": "user", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.User" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.User" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/avatar": { - "post": { - "description": "Accepts a single PNG or JPEG file (max 20 MB) in a multipart form field named \"files\" and sets it as the user's avatar.", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "upload a new avatar image for a user", - "operationId": "UploadUserAvatar", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "file", - "description": "avatar image (png or jpeg, \u003c= 20 MB)", - "name": "files", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.User" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/passcode": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "create a new 2FA passcode for a user", - "operationId": "CreateUserPasscode", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "passcode setup (password required)", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Passcode" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Passcode" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/passcode/activate": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "activate 2FA with a verified passcode", - "operationId": "ActivateUserPasscode", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Passcode" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/passcode/confirm": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "verify a new 2FA passcode", - "operationId": "ConfirmUserPasscode", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "verification code", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Passcode" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/entity.Passcode" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/passcode/deactivate": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "deactivate 2FA and remove the passcode", - "operationId": "DeactivateUserPasscode", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "password for confirmation", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.Passcode" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/password": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users", - "Authentication" - ], - "summary": "change a user's password", - "operationId": "UpdateUserPassword", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "description": "old and new password", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.ChangePassword" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/users/{uid}/sessions": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Users", - "Authentication" - ], - "summary": "list sessions for a user", - "operationId": "FindUserSessions", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "maximum": 100000, - "minimum": 1, - "type": "integer", - "description": "maximum number of results", - "name": "count", - "in": "query", - "required": true - }, - { - "minimum": 0, - "type": "integer", - "description": "result offset", - "name": "offset", - "in": "query" - }, - { - "type": "string", - "description": "filter by username or client name", - "name": "q", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/entity.Session" - } - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/videos/{hash}/{token}/{format}": { - "get": { - "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/thumbnails/#video-endpoint-uri", - "produces": [ - "video/mp4" - ], - "tags": [ - "Files", - "Videos" - ], - "summary": "returns a video, optionally limited to a byte range for streaming", - "operationId": "GetVideo", - "parameters": [ - { - "type": "string", - "description": "SHA1 video file hash", - "name": "thumb", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "user-specific security token provided with session", - "name": "token", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "video format, e.g. mp4", - "name": "format", - "in": "path", - "required": true - } - ], - "responses": { - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/vision/caption": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Vision" - ], - "summary": "returns a suitable caption for an image", - "operationId": "PostVisionCaption", - "parameters": [ - { - "description": "list of image file urls", - "name": "images", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/vision.ApiRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/vision.ApiResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "501": { - "description": "Not Implemented", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/vision/face": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Vision" - ], - "summary": "returns the embeddings of a face image", - "operationId": "PostVisionFace", - "parameters": [ - { - "description": "list of image file urls", - "name": "images", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/vision.ApiRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/vision.ApiResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "501": { - "description": "Not Implemented", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/vision/labels": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Vision" - ], - "summary": "returns suitable labels for an image", - "operationId": "PostVisionLabels", - "parameters": [ - { - "description": "list of image file urls", - "name": "images", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/vision.ApiRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/vision.ApiResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/vision/nsfw": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Vision" - ], - "summary": "checks the specified images for inappropriate content", - "operationId": "PostVisionNsfw", - "parameters": [ - { - "description": "list of image file urls", - "name": "images", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/vision.ApiRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/vision.ApiResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/zip": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "Download" - ], - "summary": "creates a zip file archive for download", - "operationId": "ZipCreate", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/zip/{filename}": { - "get": { - "produces": [ - "application/zip" - ], - "tags": [ - "Download" - ], - "summary": "returns a zip file archive after it has been created", - "operationId": "ZipDownload", - "parameters": [ - { - "type": "string", - "description": "zip archive filename returned by the POST /api/v1/zip endpoint", - "name": "filename", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - }, - "/api/v1/{any}": { - "options": { - "description": "A preflight request is automatically issued by a browser and in normal cases, front-end developers don't need to craft such requests themselves. It appears when request is qualified as \"to be preflighted\" and omitted for simple requests.", - "tags": [ - "CORS" - ], - "summary": "returns CORS headers with an empty response body", - "operationId": "Options", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/api/v1/{entity}/{uid}/links": { - "post": { - "tags": [ - "Links" - ], - "responses": {} - } - }, - "/users/{uid}/upload/{token}": { - "put": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users", - "Files" - ], - "summary": "process previously uploaded files for a user", - "operationId": "ProcessUserUpload", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "upload token", - "name": "token", - "in": "path", - "required": true - }, - { - "description": "processing options", - "name": "options", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/form.UploadOptions" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "409": { - "description": "Conflict", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - }, - "post": { - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users", - "Files" - ], - "summary": "upload files to a user's upload folder", - "operationId": "UploadUserFiles", - "parameters": [ - { - "type": "string", - "description": "user uid", - "name": "uid", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "upload token", - "name": "token", - "in": "path", - "required": true - }, - { - "type": "file", - "description": "one or more files to upload (repeat the field for multiple files)", - "name": "files", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "413": { - "description": "Request Entity Too Large", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - }, - "507": { - "description": "Insufficient Storage", - "schema": { - "$ref": "#/definitions/i18n.Response" - } - } - } - } - } - }, "definitions": { + "api.FoldersResponse": { + "properties": { + "cached": { + "type": "boolean" + }, + "files": { + "items": { + "$ref": "#/definitions/entity.File" + }, + "type": "array" + }, + "folders": { + "items": { + "$ref": "#/definitions/entity.Folder" + }, + "type": "array" + }, + "recursive": { + "type": "boolean" + }, + "root": { + "type": "string" + } + }, + "type": "object" + }, "api.HealthResponse": { - "type": "object", "properties": { "status": { "type": "string" @@ -7282,10 +34,10 @@ "time": { "type": "string" } - } + }, + "type": "object" }, "authn.GrantType": { - "type": "string", "enum": [ "", "cli", @@ -7300,6 +52,7 @@ "urn:ietf:params:oauth:grant-type:saml2-bearer", "urn:ietf:params:oauth:grant-type:token-exchange" ], + "type": "string", "x-enum-varnames": [ "GrantUndefined", "GrantCLI", @@ -7316,13 +69,13 @@ ] }, "batch.Action": { - "type": "string", "enum": [ "none", "update", "add", "remove" ], + "type": "string", "x-enum-varnames": [ "ActionNone", "ActionUpdate", @@ -7331,7 +84,6 @@ ] }, "batch.Bool": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7342,10 +94,10 @@ "value": { "type": "boolean" } - } + }, + "type": "object" }, "batch.Float32": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7356,10 +108,10 @@ "value": { "type": "number" } - } + }, + "type": "object" }, "batch.Float64": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7370,10 +122,10 @@ "value": { "type": "number" } - } + }, + "type": "object" }, "batch.Int": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7384,10 +136,10 @@ "value": { "type": "integer" } - } + }, + "type": "object" }, "batch.Item": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7401,27 +153,27 @@ "value": { "type": "string" } - } + }, + "type": "object" }, "batch.Items": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" }, "items": { - "type": "array", "items": { "$ref": "#/definitions/batch.Item" - } + }, + "type": "array" }, "mixed": { "type": "boolean" } - } + }, + "type": "object" }, "batch.PhotosForm": { - "type": "object", "properties": { "Albums": { "$ref": "#/definitions/batch.Items" @@ -7513,38 +265,38 @@ "Year": { "$ref": "#/definitions/batch.Int" } - } + }, + "type": "object" }, "batch.PhotosRequest": { - "type": "object", "properties": { "photos": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "values": { "$ref": "#/definitions/batch.PhotosForm" } - } + }, + "type": "object" }, "batch.PhotosResponse": { - "type": "object", "properties": { "models": { - "type": "array", "items": { "$ref": "#/definitions/search.Photo" - } + }, + "type": "array" }, "values": { "$ref": "#/definitions/batch.PhotosForm" } - } + }, + "type": "object" }, "batch.String": { - "type": "object", "properties": { "action": { "$ref": "#/definitions/batch.Action" @@ -7555,10 +307,10 @@ "value": { "type": "string" } - } + }, + "type": "object" }, "cluster.DatabaseInfo": { - "type": "object", "properties": { "driver": { "type": "string" @@ -7569,10 +321,10 @@ "port": { "type": "integer" } - } + }, + "type": "object" }, "cluster.Node": { - "type": "object", "properties": { "advertiseUrl": { "type": "string" @@ -7587,10 +339,10 @@ "type": "string" }, "labels": { - "type": "object", "additionalProperties": { "type": "string" - } + }, + "type": "object" }, "name": { "type": "string" @@ -7604,10 +356,10 @@ "updatedAt": { "type": "string" } - } + }, + "type": "object" }, "cluster.NodeDatabase": { - "type": "object", "properties": { "name": { "type": "string" @@ -7618,10 +370,10 @@ "user": { "type": "string" } - } + }, + "type": "object" }, "cluster.RegisterDatabase": { - "type": "object", "properties": { "dsn": { "type": "string" @@ -7644,10 +396,10 @@ "user": { "type": "string" } - } + }, + "type": "object" }, "cluster.RegisterResponse": { - "type": "object", "properties": { "alreadyProvisioned": { "type": "boolean" @@ -7664,10 +416,10 @@ "secrets": { "$ref": "#/definitions/cluster.RegisterSecrets" } - } + }, + "type": "object" }, "cluster.RegisterSecrets": { - "type": "object", "properties": { "nodeSecret": { "type": "string" @@ -7675,18 +427,18 @@ "secretRotatedAt": { "type": "string" } - } + }, + "type": "object" }, "cluster.StatusResponse": { - "type": "object", "properties": { "status": { "type": "string" } - } + }, + "type": "object" }, "cluster.SummaryResponse": { - "type": "object", "properties": { "UUID": { "type": "string" @@ -7700,10 +452,10 @@ "time": { "type": "string" } - } + }, + "type": "object" }, "config.Options": { - "type": "object", "properties": { "AppColor": { "type": "string" @@ -7724,12 +476,12 @@ "type": "integer" }, "BackupAlbums": { - "type": "boolean", - "default": true + "default": true, + "type": "boolean" }, "BackupDatabase": { - "type": "boolean", - "default": true + "default": true, + "type": "boolean" }, "BackupRetain": { "type": "integer" @@ -7915,8 +667,8 @@ "type": "integer" }, "SidecarYaml": { - "type": "boolean", - "default": true + "default": true, + "type": "boolean" }, "SiteAuthor": { "type": "string" @@ -7981,10 +733,10 @@ "WallpaperUri": { "type": "string" } - } + }, + "type": "object" }, "customize.AlbumsOrder": { - "type": "object", "properties": { "album": { "type": "string" @@ -8001,10 +753,10 @@ "state": { "type": "string" } - } + }, + "type": "object" }, "customize.AlbumsSettings": { - "type": "object", "properties": { "download": { "$ref": "#/definitions/customize.DownloadSettings" @@ -8012,15 +764,16 @@ "order": { "$ref": "#/definitions/customize.AlbumsOrder" } - } + }, + "type": "object" }, "customize.DownloadName": { - "type": "string", "enum": [ "file", "original", "share" ], + "type": "string", "x-enum-varnames": [ "DownloadNameFile", "DownloadNameOriginal", @@ -8028,7 +781,6 @@ ] }, "customize.DownloadSettings": { - "type": "object", "properties": { "disabled": { "type": "boolean" @@ -8045,10 +797,10 @@ "originals": { "type": "boolean" } - } + }, + "type": "object" }, "customize.FeatureSettings": { - "type": "object", "properties": { "account": { "type": "boolean" @@ -8134,10 +886,10 @@ "videos": { "type": "boolean" } - } + }, + "type": "object" }, "customize.ImportSettings": { - "type": "object", "properties": { "dest": { "type": "string" @@ -8148,10 +900,10 @@ "path": { "type": "string" } - } + }, + "type": "object" }, "customize.IndexSettings": { - "type": "object", "properties": { "convert": { "type": "boolean" @@ -8165,10 +917,10 @@ "skipArchived": { "type": "boolean" } - } + }, + "type": "object" }, "customize.MapsSettings": { - "type": "object", "properties": { "animate": { "type": "integer" @@ -8176,10 +928,10 @@ "style": { "type": "string" } - } + }, + "type": "object" }, "customize.SearchSettings": { - "type": "object", "properties": { "batchSize": { "type": "integer" @@ -8193,10 +945,10 @@ "showTitles": { "type": "boolean" } - } + }, + "type": "object" }, "customize.Settings": { - "type": "object", "properties": { "albums": { "$ref": "#/definitions/customize.AlbumsSettings" @@ -8231,18 +983,18 @@ "ui": { "$ref": "#/definitions/customize.UISettings" } - } + }, + "type": "object" }, "customize.ShareSettings": { - "type": "object", "properties": { "title": { "type": "string" } - } + }, + "type": "object" }, "customize.StackSettings": { - "type": "object", "properties": { "meta": { "type": "boolean" @@ -8253,18 +1005,18 @@ "uuid": { "type": "boolean" } - } + }, + "type": "object" }, "customize.TemplateSettings": { - "type": "object", "properties": { "default": { "type": "string" } - } + }, + "type": "object" }, "customize.UISettings": { - "type": "object", "properties": { "language": { "type": "string" @@ -8284,10 +1036,10 @@ "zoom": { "type": "boolean" } - } + }, + "type": "object" }, "entity.Album": { - "type": "object", "properties": { "Caption": { "type": "string" @@ -8376,10 +1128,10 @@ "Year": { "type": "integer" } - } + }, + "type": "object" }, "entity.Camera": { - "type": "object", "properties": { "Description": { "type": "string" @@ -8405,10 +1157,10 @@ "Type": { "type": "string" } - } + }, + "type": "object" }, "entity.Cell": { - "type": "object", "properties": { "Category": { "type": "string" @@ -8434,10 +1186,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Details": { - "type": "object", "properties": { "Artist": { "type": "string" @@ -8490,10 +1242,10 @@ "updatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Error": { - "type": "object", "properties": { "ID": { "type": "integer" @@ -8507,10 +1259,10 @@ "Time": { "type": "string" } - } + }, + "type": "object" }, "entity.Face": { - "type": "object", "properties": { "CollisionRadius": { "type": "number" @@ -8548,10 +1300,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.File": { - "type": "object", "properties": { "AspectRatio": { "type": "number" @@ -8697,10 +1449,72 @@ "Width": { "type": "integer" } - } + }, + "type": "object" + }, + "entity.Folder": { + "properties": { + "Category": { + "type": "string" + }, + "Country": { + "type": "string" + }, + "Day": { + "type": "integer" + }, + "Description": { + "type": "string" + }, + "Favorite": { + "type": "boolean" + }, + "FileCount": { + "type": "integer" + }, + "Ignore": { + "type": "boolean" + }, + "ModifiedAt": { + "type": "string" + }, + "Month": { + "type": "integer" + }, + "Order": { + "type": "string" + }, + "Path": { + "type": "string" + }, + "Private": { + "type": "boolean" + }, + "PublishedAt": { + "type": "string" + }, + "Root": { + "type": "string" + }, + "Title": { + "type": "string" + }, + "Type": { + "type": "string" + }, + "UID": { + "type": "string" + }, + "Watch": { + "type": "boolean" + }, + "Year": { + "type": "integer" + } + }, + "type": "object" }, "entity.Label": { - "type": "object", "properties": { "CreatedAt": { "type": "string" @@ -8750,10 +1564,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Lens": { - "type": "object", "properties": { "Description": { "type": "string" @@ -8779,10 +1593,10 @@ "Type": { "type": "string" } - } + }, + "type": "object" }, "entity.Link": { - "type": "object", "properties": { "Comment": { "type": "string" @@ -8823,10 +1637,10 @@ "Views": { "type": "integer" } - } + }, + "type": "object" }, "entity.Marker": { - "type": "object", "properties": { "FaceDist": { "type": "number" @@ -8894,10 +1708,10 @@ "updatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Passcode": { - "type": "object", "properties": { "ActivatedAt": { "type": "string" @@ -8917,16 +1731,16 @@ "VerifiedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Photo": { - "type": "object", "properties": { "Albums": { - "type": "array", "items": { "$ref": "#/definitions/entity.Album" - } + }, + "type": "array" }, "Altitude": { "type": "integer" @@ -9103,27 +1917,27 @@ "type": "string" }, "files": { - "type": "array", "items": { "$ref": "#/definitions/entity.File" - } + }, + "type": "array" }, "id": { "type": "integer" }, "labels": { - "type": "array", "items": { "$ref": "#/definitions/entity.PhotoLabel" - } + }, + "type": "array" }, "updatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.PhotoLabel": { - "type": "object", "properties": { "label": { "$ref": "#/definitions/entity.Label" @@ -9143,10 +1957,10 @@ "uncertainty": { "type": "integer" } - } + }, + "type": "object" }, "entity.Place": { - "type": "object", "properties": { "City": { "type": "string" @@ -9181,10 +1995,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Service": { - "type": "object", "properties": { "AccError": { "type": "string" @@ -9264,10 +2078,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.Session": { - "type": "object", "properties": { "AuthID": { "type": "string" @@ -9335,10 +2149,10 @@ "UserUID": { "type": "string" } - } + }, + "type": "object" }, "entity.Subject": { - "type": "object", "properties": { "About": { "type": "string" @@ -9400,10 +2214,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.User": { - "type": "object", "properties": { "Attr": { "type": "string" @@ -9472,10 +2286,10 @@ "$ref": "#/definitions/entity.UserSettings" }, "Shares": { - "type": "array", "items": { "$ref": "#/definitions/entity.UserShare" - } + }, + "type": "array" }, "SuperAdmin": { "type": "boolean" @@ -9504,10 +2318,10 @@ "WebDAV": { "type": "boolean" } - } + }, + "type": "object" }, "entity.UserDetails": { - "type": "object", "properties": { "About": { "type": "string" @@ -9596,10 +2410,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "entity.UserSettings": { - "type": "object", "properties": { "CreatedAt": { "type": "string" @@ -9658,10 +2472,10 @@ "UploadPath": { "type": "string" } - } + }, + "type": "object" }, "entity.UserShare": { - "type": "object", "properties": { "Comment": { "type": "string" @@ -9684,10 +2498,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "form.Album": { - "type": "object", "properties": { "Caption": { "type": "string" @@ -9734,10 +2548,10 @@ "Type": { "type": "string" } - } + }, + "type": "object" }, "form.ChangePassword": { - "type": "object", "properties": { "new": { "type": "string" @@ -9745,10 +2559,18 @@ "old": { "type": "string" } - } + }, + "type": "object" + }, + "form.Connect": { + "properties": { + "Token": { + "type": "string" + } + }, + "type": "object" }, "form.Details": { - "type": "object", "properties": { "Artist": { "type": "string" @@ -9789,10 +2611,10 @@ "SubjectSrc": { "type": "string" } - } + }, + "type": "object" }, "form.Face": { - "type": "object", "properties": { "Hidden": { "type": "boolean" @@ -9800,10 +2622,10 @@ "SubjUID": { "type": "string" } - } + }, + "type": "object" }, "form.Feedback": { - "type": "object", "properties": { "Category": { "type": "string" @@ -9823,24 +2645,24 @@ "UserName": { "type": "string" } - } + }, + "type": "object" }, "form.File": { - "type": "object", "properties": { "Orientation": { "type": "integer" } - } + }, + "type": "object" }, "form.ImportOptions": { - "type": "object", "properties": { "albums": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "move": { "type": "boolean" @@ -9848,10 +2670,10 @@ "path": { "type": "string" } - } + }, + "type": "object" }, "form.IndexOptions": { - "type": "object", "properties": { "cleanup": { "type": "boolean" @@ -9862,10 +2684,10 @@ "rescan": { "type": "boolean" } - } + }, + "type": "object" }, "form.Label": { - "type": "object", "properties": { "Description": { "type": "string" @@ -9891,10 +2713,10 @@ "Uncertainty": { "type": "integer" } - } + }, + "type": "object" }, "form.Link": { - "type": "object", "properties": { "CanComment": { "type": "boolean" @@ -9917,10 +2739,10 @@ "Token": { "type": "string" } - } + }, + "type": "object" }, "form.Login": { - "type": "object", "properties": { "code": { "description": "2FA Verification Code (Passcodes).", @@ -9942,10 +2764,10 @@ "description": "The local Username or LDAP user principal name (UPN).", "type": "string" } - } + }, + "type": "object" }, "form.Marker": { - "type": "object", "properties": { "FileUID": { "type": "string" @@ -9980,10 +2802,10 @@ "Y": { "type": "number" } - } + }, + "type": "object" }, "form.OAuthCreateToken": { - "type": "object", "properties": { "assertion": { "type": "string" @@ -10024,13 +2846,10 @@ "username": { "type": "string" } - } + }, + "type": "object" }, "form.OAuthRevokeToken": { - "type": "object", - "required": [ - "token" - ], "properties": { "token": { "type": "string" @@ -10038,10 +2857,13 @@ "token_type_hint": { "type": "string" } - } + }, + "required": [ + "token" + ], + "type": "object" }, "form.Passcode": { - "type": "object", "properties": { "code": { "type": "string" @@ -10052,10 +2874,10 @@ "type": { "type": "string" } - } + }, + "type": "object" }, "form.Photo": { - "type": "object", "properties": { "Altitude": { "type": "integer" @@ -10168,54 +2990,54 @@ "Year": { "type": "integer" } - } + }, + "type": "object" }, "form.Selection": { - "type": "object", "properties": { "albums": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "all": { "type": "boolean" }, "files": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "labels": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "photos": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "places": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "subjects": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" } - } + }, + "type": "object" }, "form.Service": { - "type": "object", "properties": { "AccError": { "type": "string" @@ -10284,10 +3106,10 @@ "SyncUpload": { "type": "boolean" } - } + }, + "type": "object" }, "form.Subject": { - "type": "object", "properties": { "About": { "type": "string" @@ -10322,21 +3144,21 @@ "ThumbSrc": { "type": "string" } - } + }, + "type": "object" }, "form.UploadOptions": { - "type": "object", "properties": { "albums": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" } - } + }, + "type": "object" }, "form.User": { - "type": "object", "properties": { "Attr": { "type": "string" @@ -10389,10 +3211,10 @@ "WebDAV": { "type": "boolean" } - } + }, + "type": "object" }, "form.UserDetails": { - "type": "object", "properties": { "About": { "type": "string" @@ -10466,14 +3288,14 @@ "SiteURL": { "type": "string" } - } + }, + "type": "object" }, "gin.H": { - "type": "object", - "additionalProperties": {} + "additionalProperties": {}, + "type": "object" }, "i18n.Response": { - "type": "object", "properties": { "code": { "type": "integer" @@ -10487,10 +3309,10 @@ "message": { "type": "string" } - } + }, + "type": "object" }, "io_prometheus_client.Bucket": { - "type": "object", "properties": { "cumulative_count": { "description": "Cumulative in increasing order.", @@ -10507,10 +3329,10 @@ "description": "Inclusive.", "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.BucketSpan": { - "type": "object", "properties": { "length": { "description": "Length of consecutive buckets.", @@ -10520,10 +3342,10 @@ "description": "Gap to previous span, or starting point for 1st span (which can be negative).", "type": "integer" } - } + }, + "type": "object" }, "io_prometheus_client.Counter": { - "type": "object", "properties": { "created_timestamp": { "$ref": "#/definitions/timestamppb.Timestamp" @@ -10534,99 +3356,99 @@ "value": { "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.Exemplar": { - "type": "object", "properties": { "label": { - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.LabelPair" - } + }, + "type": "array" }, "timestamp": { - "description": "OpenMetrics-style.", "allOf": [ { "$ref": "#/definitions/timestamppb.Timestamp" } - ] + ], + "description": "OpenMetrics-style." }, "value": { "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.Gauge": { - "type": "object", "properties": { "value": { "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.Histogram": { - "type": "object", "properties": { "bucket": { "description": "Buckets for the conventional histogram.", - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.Bucket" - } + }, + "type": "array" }, "created_timestamp": { "$ref": "#/definitions/timestamppb.Timestamp" }, "exemplars": { "description": "Only used for native histograms. These exemplars MUST have a timestamp.", - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.Exemplar" - } + }, + "type": "array" }, "negative_count": { "description": "Absolute count of each bucket.", - "type": "array", "items": { "type": "number" - } + }, + "type": "array" }, "negative_delta": { "description": "Use either \"negative_delta\" or \"negative_count\", the former for\nregular histograms with integer counts, the latter for float\nhistograms.", - "type": "array", "items": { "type": "integer" - } + }, + "type": "array" }, "negative_span": { "description": "Negative buckets for the native histogram.", - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.BucketSpan" - } + }, + "type": "array" }, "positive_count": { "description": "Absolute count of each bucket.", - "type": "array", "items": { "type": "number" - } + }, + "type": "array" }, "positive_delta": { "description": "Use either \"positive_delta\" or \"positive_count\", the former for\nregular histograms with integer counts, the latter for float\nhistograms.", - "type": "array", "items": { "type": "integer" - } + }, + "type": "array" }, "positive_span": { "description": "Positive buckets for the native histogram.\nUse a no-op span (offset 0, length 0) for a native histogram without any\nobservations yet and with a zero_threshold of 0. Otherwise, it would be\nindistinguishable from a classic histogram.", - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.BucketSpan" - } + }, + "type": "array" }, "sample_count": { "type": "integer" @@ -10654,10 +3476,10 @@ "description": "Breadth of the zero bucket.", "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.LabelPair": { - "type": "object", "properties": { "name": { "type": "string" @@ -10665,10 +3487,10 @@ "value": { "type": "string" } - } + }, + "type": "object" }, "io_prometheus_client.Metric": { - "type": "object", "properties": { "counter": { "$ref": "#/definitions/io_prometheus_client.Counter" @@ -10680,10 +3502,10 @@ "$ref": "#/definitions/io_prometheus_client.Histogram" }, "label": { - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.LabelPair" - } + }, + "type": "array" }, "summary": { "$ref": "#/definitions/io_prometheus_client.Summary" @@ -10694,19 +3516,19 @@ "untyped": { "$ref": "#/definitions/io_prometheus_client.Untyped" } - } + }, + "type": "object" }, "io_prometheus_client.MetricFamily": { - "type": "object", "properties": { "help": { "type": "string" }, "metric": { - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.Metric" - } + }, + "type": "array" }, "name": { "type": "string" @@ -10717,11 +3539,10 @@ "unit": { "type": "string" } - } + }, + "type": "object" }, "io_prometheus_client.MetricType": { - "type": "integer", - "format": "int32", "enum": [ 0, 1, @@ -10730,6 +3551,8 @@ 4, 5 ], + "format": "int32", + "type": "integer", "x-enum-varnames": [ "MetricType_COUNTER", "MetricType_GAUGE", @@ -10740,7 +3563,6 @@ ] }, "io_prometheus_client.Quantile": { - "type": "object", "properties": { "quantile": { "type": "number" @@ -10748,19 +3570,19 @@ "value": { "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.Summary": { - "type": "object", "properties": { "created_timestamp": { "$ref": "#/definitions/timestamppb.Timestamp" }, "quantile": { - "type": "array", "items": { "$ref": "#/definitions/io_prometheus_client.Quantile" - } + }, + "type": "array" }, "sample_count": { "type": "integer" @@ -10768,43 +3590,43 @@ "sample_sum": { "type": "number" } - } + }, + "type": "object" }, "io_prometheus_client.Untyped": { - "type": "object", "properties": { "value": { "type": "number" } - } + }, + "type": "object" }, "nsfw.Result": { - "type": "object", "properties": { "drawing": { - "type": "number", - "format": "float32" + "format": "float32", + "type": "number" }, "hentai": { - "type": "number", - "format": "float32" + "format": "float32", + "type": "number" }, "neutral": { - "type": "number", - "format": "float32" + "format": "float32", + "type": "number" }, "porn": { - "type": "number", - "format": "float32" + "format": "float32", + "type": "number" }, "sexy": { - "type": "number", - "format": "float32" + "format": "float32", + "type": "number" } - } + }, + "type": "object" }, "places.Location": { - "type": "object", "properties": { "category": { "type": "string" @@ -10842,10 +3664,10 @@ "timezone": { "type": "string" } - } + }, + "type": "object" }, "places.Place": { - "type": "object", "properties": { "city": { "type": "string" @@ -10868,16 +3690,16 @@ "state": { "type": "string" } - } + }, + "type": "object" }, "places.SearchResult": { - "type": "object", "properties": { "bbox": { - "type": "array", "items": { "type": "number" - } + }, + "type": "array" }, "city": { "type": "string" @@ -10903,10 +3725,10 @@ "name": { "type": "string" } - } + }, + "type": "object" }, "search.Album": { - "type": "object", "properties": { "Caption": { "type": "string" @@ -10992,10 +3814,10 @@ "Year": { "type": "integer" } - } + }, + "type": "object" }, "search.Face": { - "type": "object", "properties": { "CollisionRadius": { "type": "number" @@ -11060,10 +3882,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "search.GeoResult": { - "type": "object", "properties": { "Caption": { "type": "string" @@ -11107,10 +3929,10 @@ "Width": { "type": "integer" } - } + }, + "type": "object" }, "search.Label": { - "type": "object", "properties": { "CreatedAt": { "type": "string" @@ -11157,10 +3979,10 @@ "UpdatedAt": { "type": "string" } - } + }, + "type": "object" }, "search.Photo": { - "type": "object", "properties": { "Altitude": { "type": "integer" @@ -11260,10 +4082,10 @@ }, "Files": { "description": "List of files if search results are merged.", - "type": "array", "items": { "$ref": "#/definitions/entity.File" - } + }, + "type": "array" }, "FocalLength": { "type": "integer" @@ -11386,10 +4208,10 @@ "Year": { "type": "integer" } - } + }, + "type": "object" }, "search.Subject": { - "type": "object", "properties": { "Alias": { "type": "string" @@ -11436,10 +4258,10 @@ "UID": { "type": "string" } - } + }, + "type": "object" }, "sql.NullTime": { - "type": "object", "properties": { "time": { "type": "string" @@ -11448,19 +4270,19 @@ "description": "Valid is true if Time is not NULL", "type": "boolean" } - } + }, + "type": "object" }, "tensorflow.ColorChannelOrder": { - "type": "integer", "enum": [ 0 ], + "type": "integer", "x-enum-varnames": [ "UndefinedOrder" ] }, "tensorflow.Interval": { - "type": "object", "properties": { "end": { "type": "number" @@ -11474,10 +4296,10 @@ "stdDev": { "type": "number" } - } + }, + "type": "object" }, "tensorflow.ModelInfo": { - "type": "object", "properties": { "input": { "$ref": "#/definitions/tensorflow.PhotoInput" @@ -11486,15 +4308,15 @@ "$ref": "#/definitions/tensorflow.ModelOutput" }, "tags": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" } - } + }, + "type": "object" }, "tensorflow.ModelOutput": { - "type": "object", "properties": { "index": { "type": "integer" @@ -11508,10 +4330,10 @@ "outputs": { "type": "integer" } - } + }, + "type": "object" }, "tensorflow.PhotoInput": { - "type": "object", "properties": { "height": { "type": "integer" @@ -11523,10 +4345,10 @@ "$ref": "#/definitions/tensorflow.ColorChannelOrder" }, "intervals": { - "type": "array", "items": { "$ref": "#/definitions/tensorflow.Interval" - } + }, + "type": "array" }, "name": { "type": "string" @@ -11535,24 +4357,25 @@ "$ref": "#/definitions/tensorflow.ResizeOperation" }, "shape": { - "type": "array", "items": { "$ref": "#/definitions/tensorflow.ShapeComponent" - } + }, + "type": "array" }, "width": { "type": "integer" } - } + }, + "type": "object" }, "tensorflow.ResizeOperation": { - "type": "integer", "enum": [ 0, 1, 2, 3 ], + "type": "integer", "x-enum-varnames": [ "UndefinedResizeOperation", "ResizeBreakAspectRatio", @@ -11561,36 +4384,19 @@ ] }, "tensorflow.ShapeComponent": { - "type": "string", "enum": [ "Batch" ], + "type": "string", "x-enum-varnames": [ "ShapeBatch" ] }, "time.Duration": { - "type": "integer", "format": "int64", - "enum": [ - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000 - ], - "x-enum-varnames": [ - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour" - ] + "type": "integer" }, "timestamppb.Timestamp": { - "type": "object", "properties": { "nanos": { "description": "Non-negative fractions of a second at nanosecond resolution. Negative\nsecond values with fractions must still have non-negative nanos values\nthat count forward in time. Must be from 0 to 999,999,999\ninclusive.", @@ -11600,16 +4406,17 @@ "description": "Represents seconds of UTC time since Unix epoch\n1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to\n9999-12-31T23:59:59Z inclusive.", "type": "integer" } - } + }, + "type": "object" }, "vision.ApiFormat": { - "type": "string", "enum": [ "url", "images", "vision", "ollama" ], + "type": "string", "x-enum-varnames": [ "ApiFormatUrl", "ApiFormatImages", @@ -11618,13 +4425,12 @@ ] }, "vision.ApiRequest": { - "type": "object", "properties": { "context": { - "type": "array", "items": { "type": "integer" - } + }, + "type": "array" }, "format": { "type": "string" @@ -11633,10 +4439,10 @@ "type": "string" }, "images": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "model": { "type": "string" @@ -11662,10 +4468,10 @@ "version": { "type": "string" } - } + }, + "type": "object" }, "vision.ApiRequestOptions": { - "type": "object", "properties": { "frequency_penalty": { "type": "number" @@ -11725,10 +4531,10 @@ "type": "integer" }, "stop": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "temperature": { "type": "number" @@ -11754,10 +4560,10 @@ "vocab_only": { "type": "boolean" } - } + }, + "type": "object" }, "vision.ApiResponse": { - "type": "object", "properties": { "code": { "type": "integer" @@ -11774,43 +4580,43 @@ "result": { "$ref": "#/definitions/vision.ApiResult" } - } + }, + "type": "object" }, "vision.ApiResult": { - "type": "object", "properties": { "caption": { "$ref": "#/definitions/vision.CaptionResult" }, "embeddings": { - "type": "array", "items": { - "type": "array", "items": { - "type": "array", "items": { - "type": "number", - "format": "float64" - } - } - } + "format": "float64", + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + "type": "array" }, "labels": { - "type": "array", "items": { "$ref": "#/definitions/vision.LabelResult" - } + }, + "type": "array" }, "nsfw": { - "type": "array", "items": { "$ref": "#/definitions/nsfw.Result" - } + }, + "type": "array" } - } + }, + "type": "object" }, "vision.CaptionResult": { - "type": "object", "properties": { "confidence": { "type": "number" @@ -11821,16 +4627,16 @@ "text": { "type": "string" } - } + }, + "type": "object" }, "vision.LabelResult": { - "type": "object", "properties": { "categories": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "confidence": { "type": "number" @@ -11847,10 +4653,10 @@ "topicality": { "type": "number" } - } + }, + "type": "object" }, "vision.Model": { - "type": "object", "properties": { "default": { "type": "boolean" @@ -11885,16 +4691,17 @@ "version": { "type": "string" } - } + }, + "type": "object" }, "vision.ModelType": { - "type": "string", "enum": [ "labels", "nsfw", "face", "caption" ], + "type": "string", "x-enum-varnames": [ "ModelTypeLabels", "ModelTypeNsfw", @@ -11903,7 +4710,6 @@ ] }, "vision.Service": { - "type": "object", "properties": { "disabled": { "type": "boolean" @@ -11923,14 +4729,7425 @@ "uri": { "type": "string" } - } + }, + "type": "object" } }, - "securityDefinitions": { - "BearerAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" + "externalDocs": { + "description": "Learn more ›", + "url": "https://docs.photoprism.app/developer-guide/api/" + }, + "host": "demo.photoprism.app", + "info": { + "contact": {}, + "description": "API request bodies and responses are usually JSON-encoded, except for binary data and some of the OAuth2 endpoints. Note that the `Content-Type` header must be set to `application/json` for this, as the request may otherwise fail with error 400.\nWhen clients have a valid access token, e.g. obtained through the `POST /api/v1/session` or `POST /api/v1/oauth/token` endpoint, they can use a standard Bearer Authorization header to authenticate their requests. Submitting the access token with a custom `X-Auth-Token` header is supported as well.", + "title": "PhotoPrism API", + "version": "v1" + }, + "paths": { + "/api/v1/albums": { + "get": { + "operationId": "SearchAlbums", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "sort order", + "enum": [ + "favorites", + "name", + "title", + "added", + "edited" + ], + "in": "query", + "name": "order", + "type": "string" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/search.Album" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds albums and returns them as JSON", + "tags": [ + "Albums" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "CreateAlbum", + "parameters": [ + { + "description": "properties of the album to be created (currently supports Title and Favorite)", + "in": "body", + "name": "album", + "required": true, + "schema": { + "$ref": "#/definitions/form.Album" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Album" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "creates a new album", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/albums/{uid}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DeleteAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "deletes an existing album", + "tags": [ + "Albums" + ] + }, + "get": { + "operationId": "GetAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Album" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns album details as JSON", + "tags": [ + "Albums" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated", + "in": "body", + "name": "album", + "required": true, + "schema": { + "$ref": "#/definitions/form.Album" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Album" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates album metadata like title and description", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/clone": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "CloneAlbums", + "parameters": [ + { + "description": "Album Selection", + "in": "body", + "name": "albums", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + }, + { + "description": "UID of the album to which the pictures are to be added", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "creates a new album containing pictures from other albums", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/dl": { + "get": { + "operationId": "DownloadAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/zip" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "streams the album contents as zip archive", + "tags": [ + "Albums", + "Download" + ] + } + }, + "/api/v1/albums/{uid}/like": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DislikeAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes the favorite flag from an album", + "tags": [ + "Albums" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "LikeAlbum", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "sets the favorite flag for an album", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/links": { + "get": { + "operationId": "GetAlbumLinks", + "parameters": [ + { + "description": "album uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Link" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns all share links for the given UID as JSON", + "tags": [ + "Links", + "Albums" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "CreateAlbumLink", + "parameters": [ + { + "description": "album uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "link properties (currently supported: slug, expires)", + "in": "body", + "name": "link", + "required": true, + "schema": { + "$ref": "#/definitions/form.Link" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Link" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "adds a new album share link and return it as JSON", + "tags": [ + "Links", + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/links/{linkuid}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DeleteAlbumLink", + "parameters": [ + { + "description": "album", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "link uid", + "in": "path", + "name": "linkuid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Link" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "deletes an album share link", + "tags": [ + "Links", + "Albums" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateAlbumLink", + "parameters": [ + { + "description": "album uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "link uid", + "in": "path", + "name": "linkuid", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated (currently supported: slug, expires, token)", + "in": "body", + "name": "link", + "required": true, + "schema": { + "$ref": "#/definitions/form.Link" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Link" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates an album share link and return it as JSON", + "tags": [ + "Links", + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/photos": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "RemovePhotosFromAlbum", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + }, + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes photos from an album", + "tags": [ + "Albums" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "AddPhotosToAlbum", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + }, + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "adds photos to an album", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/albums/{uid}/t/{token}/{size}": { + "get": { + "operationId": "AlbumCover", + "parameters": [ + { + "description": "Album UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "thumbnail size", + "enum": [ + "tile_50", + "tile_100", + "left_224", + "right_224", + "tile_224", + "tile_500", + "fit_720", + "tile_1080", + "fit_1280", + "fit_1600", + "fit_1920", + "fit_2048", + "fit_2560", + "fit_3840", + "fit_4096", + "fit_7680" + ], + "in": "path", + "name": "size", + "required": true, + "type": "string" + } + ], + "produces": [ + "image/jpeg", + "image/svg+xml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + } + }, + "summary": "returns an album cover image", + "tags": [ + "Images", + "Albums" + ] + } + }, + "/api/v1/batch/albums/delete": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchAlbumsDelete", + "parameters": [ + { + "description": "Album Selection", + "in": "body", + "name": "albums", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "permanently removes multiple albums", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/batch/labels/delete": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchLabelsDelete", + "parameters": [ + { + "description": "Label Selection", + "in": "body", + "name": "labels", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "deletes multiple labels", + "tags": [ + "Labels" + ] + } + }, + "/api/v1/batch/photos/approve": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosApprove", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "approves multiple photos that are currently under review", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/batch/photos/archive": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosArchive", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "moves multiple photos to the archive", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/batch/photos/delete": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosDelete", + "parameters": [ + { + "description": "All or Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "permanently removes multiple or all photos from the archive", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/batch/photos/edit": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosEdit", + "parameters": [ + { + "description": "photos selection and values", + "in": "body", + "name": "Request", + "required": true, + "schema": { + "$ref": "#/definitions/batch.PhotosRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/batch.PhotosResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns and updates the metadata of multiple photos", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/batch/photos/private": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosPrivate", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "toggles private state of multiple photos", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/batch/photos/restore": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "BatchPhotosRestore", + "parameters": [ + { + "description": "Photo Selection", + "in": "body", + "name": "photos", + "required": true, + "schema": { + "$ref": "#/definitions/form.Selection" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "restores multiple photos from the archive", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/cluster": { + "get": { + "operationId": "ClusterSummary", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/cluster.SummaryResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "cluster summary", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/cluster/health": { + "get": { + "operationId": "ClusterHealth", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "cluster health", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/cluster/nodes": { + "get": { + "operationId": "ClusterListNodes", + "parameters": [ + { + "description": "maximum number of results (default 100, max 1000)", + "in": "query", + "maximum": 1000, + "minimum": 1, + "name": "count", + "type": "integer" + }, + { + "description": "result offset", + "in": "query", + "minimum": 0, + "name": "offset", + "type": "integer" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/cluster.Node" + }, + "type": "array" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "lists registered nodes", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/cluster/nodes/register": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "ClusterNodesRegister", + "parameters": [ + { + "description": "registration payload (nodeName required; optional: nodeRole, labels, advertiseUrl, siteUrl, rotateDatabase, rotateSecret)", + "in": "body", + "name": "request", + "required": true, + "schema": { + "type": "object" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/cluster.RegisterResponse" + } + }, + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/cluster.RegisterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "registers a node, provisions DB credentials, and issues nodeSecret", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/cluster/nodes/{id}": { + "delete": { + "operationId": "ClusterDeleteNode", + "parameters": [ + { + "description": "node id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/cluster.StatusResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "delete node by id", + "tags": [ + "Cluster" + ] + }, + "get": { + "operationId": "ClusterGetNode", + "parameters": [ + { + "description": "node id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/cluster.Node" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "get node by id", + "tags": [ + "Cluster" + ] + }, + "patch": { + "consumes": [ + "application/json" + ], + "operationId": "ClusterUpdateNode", + "parameters": [ + { + "description": "node id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "properties to update (role, labels, advertiseUrl, siteUrl)", + "in": "body", + "name": "node", + "required": true, + "schema": { + "type": "object" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/cluster.StatusResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "update node fields", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/cluster/theme": { + "get": { + "operationId": "ClusterGetTheme", + "produces": [ + "application/zip" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns custom theme files as zip, if available", + "tags": [ + "Cluster" + ] + } + }, + "/api/v1/config": { + "get": { + "operationId": "GetClientConfig", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "get client configuration", + "tags": [ + "Config" + ] + } + }, + "/api/v1/config/options": { + "get": { + "operationId": "GetConfigOptions", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.Options" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns backend config options", + "tags": [ + "Config", + "Settings" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "SaveConfigOptions", + "parameters": [ + { + "description": "properties to be updated (only submit values that should be changed)", + "in": "body", + "name": "options", + "required": true, + "schema": { + "$ref": "#/definitions/config.Options" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.Options" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates backend config options", + "tags": [ + "Config", + "Settings" + ] + } + }, + "/api/v1/connect/{name}": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "ConnectService", + "parameters": [ + { + "description": "service name (e.g., hub)", + "in": "path", + "name": "name", + "required": true, + "type": "string" + }, + { + "description": "connection token", + "in": "body", + "name": "connect", + "required": true, + "schema": { + "$ref": "#/definitions/form.Connect" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "confirm external service accounts using a token", + "tags": [ + "Config" + ] + } + }, + "/api/v1/dl/{file}": { + "get": { + "operationId": "GetDownload", + "parameters": [ + { + "description": "file hash or unique download id", + "in": "path", + "name": "file", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/octet-stream" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "file" + } + } + }, + "summary": "returns the raw file data", + "tags": [ + "Images", + "Files" + ] + } + }, + "/api/v1/echo": { + "get": { + "operationId": "Echo", + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "returns the request and response headers as JSON if debug mode is enabled", + "tags": [ + "Debug" + ] + } + }, + "/api/v1/errors": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DeleteErrors", + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes all entries from the error logs", + "tags": [ + "Errors" + ] + }, + "get": { + "operationId": "GetErrors", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Error" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "searches the error logs and returns the results as JSON", + "tags": [ + "Errors" + ] + } + }, + "/api/v1/faces": { + "get": { + "operationId": "SearchFaces", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "sort order", + "enum": [ + "subject", + "added", + "samples" + ], + "in": "query", + "name": "order", + "type": "string" + }, + { + "description": "show hidden", + "enum": [ + "yes", + "no" + ], + "in": "query", + "name": "hidden", + "type": "string" + }, + { + "description": "show unknown", + "enum": [ + "yes", + "no" + ], + "in": "query", + "name": "unknown", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/search.Face" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds and returns faces as JSON", + "tags": [ + "Faces" + ] + } + }, + "/api/v1/faces/{id}": { + "get": { + "operationId": "GetFace", + "parameters": [ + { + "description": "face id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Face" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns a face as JSON", + "tags": [ + "Faces" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateFace", + "parameters": [ + { + "description": "face id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated", + "in": "body", + "name": "face", + "required": true, + "schema": { + "$ref": "#/definitions/form.Face" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Face" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates face properties", + "tags": [ + "Faces" + ] + } + }, + "/api/v1/feedback": { + "post": { + "operationId": "SendFeedback", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/form.Feedback" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "allows members to submit a feedback message to the PhotoPrism team", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/files/{hash}": { + "get": { + "operationId": "GetFile", + "parameters": [ + { + "description": "hash (string) SHA-1 hash of the file", + "in": "path", + "name": "hash", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.File" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns file details as JSON", + "tags": [ + "Files" + ] + } + }, + "/api/v1/folders/import": { + "get": { + "operationId": "SearchFoldersImport", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.FoldersResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "list folders in import", + "tags": [ + "Folders" + ] + } + }, + "/api/v1/folders/originals": { + "get": { + "operationId": "SearchFoldersOriginals", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.FoldersResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "list folders in originals", + "tags": [ + "Folders" + ] + } + }, + "/api/v1/folders/t/{uid}/{token}/{size}": { + "get": { + "operationId": "FolderCover", + "parameters": [ + { + "description": "folder uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "thumbnail size", + "enum": [ + "tile_50", + "tile_100", + "left_224", + "right_224", + "tile_224", + "tile_500", + "fit_720", + "tile_1080", + "fit_1280", + "fit_1600", + "fit_1920", + "fit_2048", + "fit_2560", + "fit_3840", + "fit_4096", + "fit_7680" + ], + "in": "path", + "name": "size", + "required": true, + "type": "string" + } + ], + "produces": [ + "image/jpeg", + "image/svg+xml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + } + }, + "summary": "returns a folder cover image", + "tags": [ + "Images", + "Folders" + ] + } + }, + "/api/v1/geo": { + "get": { + "operationId": "SearchGeo", + "parameters": [ + { + "description": "maximum number of files", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "file offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "excludes private pictures", + "in": "query", + "name": "public", + "type": "boolean" + }, + { + "description": "minimum quality score (1-7)", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "in": "query", + "name": "quality", + "required": true, + "type": "integer" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + }, + { + "description": "album uid", + "in": "query", + "name": "s", + "type": "string" + }, + { + "description": "photo path", + "in": "query", + "name": "path", + "type": "string" + }, + { + "description": "is type video", + "in": "query", + "name": "video", + "type": "boolean" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/search.GeoResult" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds photos and returns results as JSON, so they can be displayed on a map or in a viewer", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/import/": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "StartImport", + "parameters": [ + { + "description": "import options", + "in": "body", + "name": "options", + "required": true, + "schema": { + "$ref": "#/definitions/form.ImportOptions" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "start import", + "tags": [ + "Library" + ] + } + }, + "/api/v1/index": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "StartIndexing", + "parameters": [ + { + "description": "index options", + "in": "body", + "name": "options", + "required": true, + "schema": { + "$ref": "#/definitions/form.IndexOptions" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "start indexing", + "tags": [ + "Library" + ] + } + }, + "/api/v1/labels": { + "get": { + "operationId": "SearchLabels", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "show all", + "in": "query", + "name": "all", + "type": "boolean" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/search.Label" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds and returns labels as JSON", + "tags": [ + "Labels" + ] + } + }, + "/api/v1/labels/{uid}": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateLabel", + "parameters": [ + { + "description": "Label UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "Label Name", + "in": "body", + "name": "label", + "required": true, + "schema": { + "$ref": "#/definitions/form.Label" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Label" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates label name", + "tags": [ + "Labels" + ] + } + }, + "/api/v1/labels/{uid}/like": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DislikeLabel", + "parameters": [ + { + "description": "Label UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes favorite flag from a label", + "tags": [ + "Labels" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "LikeLabel", + "parameters": [ + { + "description": "Label UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "sets favorite flag for a label", + "tags": [ + "Labels" + ] + } + }, + "/api/v1/labels/{uid}/t/{token}/{size}": { + "get": { + "operationId": "LabelCover", + "parameters": [ + { + "description": "Label UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "thumbnail size", + "enum": [ + "tile_50", + "tile_100", + "left_224", + "right_224", + "tile_224", + "tile_500", + "fit_720", + "tile_1080", + "fit_1280", + "fit_1600", + "fit_1920", + "fit_2048", + "fit_2560", + "fit_3840", + "fit_4096", + "fit_7680" + ], + "in": "path", + "name": "size", + "required": true, + "type": "string" + } + ], + "produces": [ + "image/jpeg", + "image/svg+xml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + } + }, + "summary": "returns a label cover image", + "tags": [ + "Images", + "Labels" + ] + } + }, + "/api/v1/markers": { + "post": { + "responses": {}, + "tags": [ + "Files" + ] + } + }, + "/api/v1/markers/{marker_uid}": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateMarker", + "parameters": [ + { + "description": "marker uid", + "in": "path", + "name": "marker_uid", + "required": true, + "type": "string" + }, + { + "description": "marker properties", + "in": "body", + "name": "marker", + "required": true, + "schema": { + "$ref": "#/definitions/form.Marker" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Marker" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "update a marker (face/subject region)", + "tags": [ + "Files" + ] + } + }, + "/api/v1/markers/{marker_uid}/subject": { + "delete": { + "operationId": "ClearMarkerSubject", + "parameters": [ + { + "description": "marker uid", + "in": "path", + "name": "marker_uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Marker" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "clear the subject of a marker", + "tags": [ + "Files" + ] + } + }, + "/api/v1/metrics": { + "get": { + "operationId": "GetMetrics", + "produces": [ + "text/event-stream" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/io_prometheus_client.MetricFamily" + }, + "type": "array" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "a prometheus-compatible metrics endpoint for monitoring this instance", + "tags": [ + "Metrics" + ] + } + }, + "/api/v1/moments/time": { + "get": { + "operationId": "GetMomentsTime", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Album" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns monthly albums as JSON", + "tags": [ + "Albums" + ] + } + }, + "/api/v1/oauth/authorize": { + "get": { + "operationId": "OAuthAuthorize", + "produces": [ + "application/json" + ], + "responses": { + "405": { + "description": "Method Not Allowed", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "OAuth2 authorization endpoint (not implemented)", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/oauth/revoke": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "OAuthRevoke", + "parameters": [ + { + "description": "revoke request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.OAuthRevokeToken" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "revoke an OAuth2 access token or session", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/oauth/token": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "OAuthToken", + "parameters": [ + { + "description": "token request (supports client_credentials, password, or session grant)", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.OAuthCreateToken" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "create an OAuth2 access token", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/oidc/login": { + "get": { + "operationId": "OIDCLogin", + "produces": [ + "text/html" + ], + "responses": { + "307": { + "description": "redirect to provider login page", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "start OpenID Connect login (browser redirect)", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/oidc/redirect": { + "get": { + "operationId": "OIDCRedirect", + "parameters": [ + { + "description": "opaque OAuth2 state value", + "in": "query", + "name": "state", + "required": true, + "type": "string" + }, + { + "description": "authorization code", + "in": "query", + "name": "code", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "HTML page bootstrapping token storage", + "schema": { + "type": "string" + } + }, + "401": { + "description": "rendered error page", + "schema": { + "type": "string" + } + }, + "403": { + "description": "rendered error page", + "schema": { + "type": "string" + } + }, + "429": { + "description": "rendered error page", + "schema": { + "type": "string" + } + } + }, + "summary": "complete OIDC login (callback)", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/photos": { + "get": { + "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/search/#get-apiv1photos", + "operationId": "SearchPhotos", + "parameters": [ + { + "description": "maximum number of files", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "file offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "sort order", + "enum": [ + "name", + "title", + "added", + "edited", + "newest", + "oldest", + "size", + "random", + "duration", + "relevance" + ], + "in": "query", + "name": "order", + "type": "string" + }, + { + "description": "groups consecutive files that belong to the same photo", + "in": "query", + "name": "merged", + "type": "boolean" + }, + { + "description": "excludes private pictures", + "in": "query", + "name": "public", + "type": "boolean" + }, + { + "description": "minimum quality score (1-7)", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "in": "query", + "name": "quality", + "type": "integer" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + }, + { + "description": "album uid", + "in": "query", + "name": "s", + "type": "string" + }, + { + "description": "photo path", + "in": "query", + "name": "path", + "type": "string" + }, + { + "description": "is type video", + "in": "query", + "name": "video", + "type": "boolean" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/search.Photo" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds pictures and returns them as JSON", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/photos/{uid}": { + "get": { + "operationId": "GetPhoto", + "parameters": [ + { + "description": "Photo UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns picture details as JSON", + "tags": [ + "Photos" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdatePhoto", + "parameters": [ + { + "description": "Photo UID", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated (only submit values that should be changed)", + "in": "body", + "name": "photo", + "required": true, + "schema": { + "$ref": "#/definitions/form.Photo" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates picture details and returns them as JSON", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/photos/{uid}/approve": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "ApprovePhoto", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "marks a photo in review as approved", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/photos/{uid}/dl": { + "get": { + "operationId": "GetPhotoDownload", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/octet-stream" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "file" + } + } + }, + "summary": "returns the primary file matching that belongs to the photo", + "tags": [ + "Images", + "Files" + ] + } + }, + "/api/v1/photos/{uid}/files/{fileuid}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DeleteFile", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "file uid", + "in": "path", + "name": "fileuid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes a file from storage", + "tags": [ + "Files" + ] + } + }, + "/api/v1/photos/{uid}/files/{fileuid}/orientation": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "ChangeFileOrientation", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "file uid", + "in": "path", + "name": "fileuid", + "required": true, + "type": "string" + }, + { + "description": "file orientation", + "in": "body", + "name": "file", + "required": true, + "schema": { + "$ref": "#/definitions/form.File" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "changes the orientation of a file", + "tags": [ + "Files" + ] + } + }, + "/api/v1/photos/{uid}/files/{fileuid}/primary": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "PhotoPrimary", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "file uid", + "in": "path", + "name": "fileuid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "sets the primary file for a photo", + "tags": [ + "Photos", + "Stacks" + ] + } + }, + "/api/v1/photos/{uid}/files/{fileuid}/unstack": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "PhotoUnstack", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "file uid", + "in": "path", + "name": "fileuid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes a file from an existing photo stack", + "tags": [ + "Photos", + "Stacks" + ] + } + }, + "/api/v1/photos/{uid}/label": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "AddPhotoLabel", + "parameters": [ + { + "description": "label properties", + "in": "body", + "name": "label", + "required": true, + "schema": { + "$ref": "#/definitions/form.Label" + } + }, + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "adds a label to a photo", + "tags": [ + "Labels", + "Photos" + ] + } + }, + "/api/v1/photos/{uid}/label/{id}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "RemovePhotoLabel", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "label id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes a label from a photo", + "tags": [ + "Labels", + "Photos" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdatePhotoLabel", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "label id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated (currently supports: uncertainty)", + "in": "body", + "name": "label", + "required": true, + "schema": { + "$ref": "#/definitions/form.Label" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Photo" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "changes a photo label", + "tags": [ + "Labels", + "Photos" + ] + } + }, + "/api/v1/photos/{uid}/like": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DislikePhoto", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes the favorite flags from a photo", + "tags": [ + "Photos" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "LikePhoto", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "flags a photo as favorite", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/photos/{uid}/yaml": { + "get": { + "operationId": "GetPhotoYaml", + "parameters": [ + { + "description": "photo uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/x-yaml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns picture details as YAML", + "tags": [ + "Photos" + ] + } + }, + "/api/v1/places/reverse": { + "get": { + "operationId": "GetPlacesReverse", + "parameters": [ + { + "description": "Latitude", + "in": "query", + "name": "lat", + "required": true, + "type": "string" + }, + { + "description": "Longitude", + "in": "query", + "name": "lng", + "required": true, + "type": "string" + }, + { + "description": "Locale", + "in": "query", + "name": "locale", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/places.Location" + } + }, + "400": { + "description": "Missing latitude or longitude", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Geocoding service error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + }, + "summary": "returns location details for the specified coordinates", + "tags": [ + "Places" + ] + } + }, + "/api/v1/places/search": { + "get": { + "operationId": "GetPlacesSearch", + "parameters": [ + { + "description": "Search query", + "in": "query", + "name": "q", + "required": true, + "type": "string" + }, + { + "description": "Locale for results (default: en)", + "in": "query", + "name": "locale", + "type": "string" + }, + { + "description": "Maximum number of results (default: 10, max: 50)", + "in": "query", + "name": "count", + "type": "integer" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/places.SearchResult" + }, + "type": "array" + } + }, + "400": { + "description": "Missing search query", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Search service error", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + }, + "summary": "returns locations that match the specified search query", + "tags": [ + "Places" + ] + } + }, + "/api/v1/server/stop": { + "post": { + "operationId": "StopServer", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.Options" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "allows authorized admins to restart the server", + "tags": [ + "Admin" + ] + } + }, + "/api/v1/services": { + "get": { + "operationId": "SearchServices", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/entity.Service" + }, + "type": "array" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds services and returns them as JSON", + "tags": [ + "Services" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "AddService", + "parameters": [ + { + "description": "properties of the service to be created", + "in": "body", + "name": "service", + "required": true, + "schema": { + "$ref": "#/definitions/form.Service" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Service" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "creates a new remote service account configuration", + "tags": [ + "Services" + ] + } + }, + "/api/v1/services/{id}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DeleteService", + "parameters": [ + { + "description": "service id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Service" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes a remote service account configuration", + "tags": [ + "Services" + ] + }, + "get": { + "operationId": "GetService", + "parameters": [ + { + "description": "service id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Service" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns the specified remote service account configuration as JSON", + "tags": [ + "Services" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateService", + "parameters": [ + { + "description": "service id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated (only submit values that should be changed)", + "in": "body", + "name": "service", + "required": true, + "schema": { + "$ref": "#/definitions/form.Service" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Service" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates a remote account configuration", + "tags": [ + "Services" + ] + } + }, + "/api/v1/services/{id}/folders": { + "get": { + "operationId": "GetServiceFolders", + "parameters": [ + { + "description": "service id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns folders that belong to a remote service account", + "tags": [ + "Services" + ] + } + }, + "/api/v1/services/{id}/upload": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "UploadToService", + "parameters": [ + { + "description": "service id", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/entity.File" + }, + "type": "array" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "uploads files to the selected service account", + "tags": [ + "Services" + ] + } + }, + "/api/v1/session": { + "delete": { + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "delete a session (logout)", + "tags": [ + "Authentication" + ] + }, + "get": { + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "get the current session or a session by id", + "tags": [ + "Authentication" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "login credentials", + "in": "body", + "name": "credentials", + "required": true, + "schema": { + "$ref": "#/definitions/form.Login" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "create a session (login)", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/session/{id}": { + "delete": { + "parameters": [ + { + "description": "session id or ref id", + "in": "path", + "name": "id", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "delete a session (logout)", + "tags": [ + "Authentication" + ] + }, + "get": { + "parameters": [ + { + "description": "session id", + "in": "path", + "name": "id", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "get the current session or a session by id", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/sessions": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "login credentials", + "in": "body", + "name": "credentials", + "required": true, + "schema": { + "$ref": "#/definitions/form.Login" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "create a session (login)", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/sessions/{id}": { + "delete": { + "parameters": [ + { + "description": "session id or ref id", + "in": "path", + "name": "id", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "delete a session (logout)", + "tags": [ + "Authentication" + ] + }, + "get": { + "parameters": [ + { + "description": "session id", + "in": "path", + "name": "id", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "get the current session or a session by id", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/settings": { + "get": { + "operationId": "GetSettings", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/customize.Settings" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns the user app settings as JSON", + "tags": [ + "Settings" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "SaveSettings", + "parameters": [ + { + "description": "user settings", + "in": "body", + "name": "settings", + "required": true, + "schema": { + "$ref": "#/definitions/customize.Settings" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/customize.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "saves the user app settings", + "tags": [ + "Settings" + ] + } + }, + "/api/v1/status": { + "get": { + "operationId": "GetStatus", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gin.H" + } + } + }, + "summary": "responds with status code 200 if the server is operational", + "tags": [ + "Debug" + ] + } + }, + "/api/v1/subjects": { + "get": { + "operationId": "SearchSubjects", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "sort order", + "enum": [ + "name", + "count", + "added", + "relevance" + ], + "in": "query", + "name": "order", + "type": "string" + }, + { + "description": "show hidden", + "enum": [ + "yes", + "no" + ], + "in": "query", + "name": "hidden", + "type": "string" + }, + { + "description": "minimum number of files", + "in": "query", + "name": "files", + "type": "integer" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/search.Subject" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds and returns subjects as JSON", + "tags": [ + "Subjects" + ] + } + }, + "/api/v1/subjects/{uid}": { + "get": { + "operationId": "GetSubject", + "parameters": [ + { + "description": "subject uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Subject" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns a subject as JSON", + "tags": [ + "Subjects" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateSubject", + "parameters": [ + { + "description": "subject uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated (only submit values that should be changed)", + "in": "body", + "name": "subject", + "required": true, + "schema": { + "$ref": "#/definitions/form.Subject" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Subject" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates subject properties", + "tags": [ + "Subjects" + ] + } + }, + "/api/v1/subjects/{uid}/like": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "DislikeSubject", + "parameters": [ + { + "description": "subject uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "removes the favorite flag from a subject", + "tags": [ + "Subjects" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "LikeSubject", + "parameters": [ + { + "description": "subject uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "flags a subject as favorite", + "tags": [ + "Subjects" + ] + } + }, + "/api/v1/t/{thumb}/{token}/{size}": { + "get": { + "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/thumbnails/#image-endpoint-uri", + "operationId": "GetThumb", + "parameters": [ + { + "description": "SHA1 file hash, optionally with a crop area suffixed, e.g. '-016014058037'", + "in": "path", + "name": "thumb", + "required": true, + "type": "string" + }, + { + "description": "user-specific security token provided with session or 'public' when running PhotoPrism in public mode", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "thumbnail size", + "enum": [ + "tile_50", + "tile_100", + "left_224", + "right_224", + "tile_224", + "tile_500", + "fit_720", + "tile_1080", + "fit_1280", + "fit_1600", + "fit_1920", + "fit_2048", + "fit_2560", + "fit_3840", + "fit_4096", + "fit_7680" + ], + "in": "path", + "name": "size", + "required": true, + "type": "string" + } + ], + "produces": [ + "image/jpeg", + " image/svg+xml" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "file" + } + } + }, + "summary": "returns a thumbnail image with the requested size", + "tags": [ + "Images", + "Files" + ] + } + }, + "/api/v1/users/{uid}": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateUser", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "properties to be updated", + "in": "body", + "name": "user", + "required": true, + "schema": { + "$ref": "#/definitions/form.User" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "update user profile information", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/avatar": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "description": "Accepts a single PNG or JPEG file (max 20 MB) in a multipart form field named \"files\" and sets it as the user's avatar.", + "operationId": "UploadUserAvatar", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "avatar image (png or jpeg, \u003c= 20 MB)", + "in": "formData", + "name": "files", + "required": true, + "type": "file" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.User" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "upload a new avatar image for a user", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/passcode": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "CreateUserPasscode", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "passcode setup (password required)", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.Passcode" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Passcode" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "create a new 2FA passcode for a user", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/passcode/activate": { + "post": { + "operationId": "ActivateUserPasscode", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Passcode" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "activate 2FA with a verified passcode", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/passcode/confirm": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "ConfirmUserPasscode", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "verification code", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.Passcode" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Passcode" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "verify a new 2FA passcode", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/passcode/deactivate": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "DeactivateUserPasscode", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "password for confirmation", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.Passcode" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "deactivate 2FA and remove the passcode", + "tags": [ + "Users" + ] + } + }, + "/api/v1/users/{uid}/password": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateUserPassword", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "old and new password", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/form.ChangePassword" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "change a user's password", + "tags": [ + "Users", + "Authentication" + ] + } + }, + "/api/v1/users/{uid}/sessions": { + "get": { + "operationId": "FindUserSessions", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "result offset", + "in": "query", + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "filter by username or client name", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/entity.Session" + }, + "type": "array" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "list sessions for a user", + "tags": [ + "Users", + "Authentication" + ] + } + }, + "/api/v1/users/{uid}/upload/{token}": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "operationId": "UploadUserFiles", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "upload token", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "one or more files to upload (repeat the field for multiple files)", + "in": "formData", + "name": "files", + "required": true, + "type": "file" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "507": { + "description": "Insufficient Storage", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "upload files to a user's upload folder", + "tags": [ + "Users", + "Files" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "ProcessUserUpload", + "parameters": [ + { + "description": "user uid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "upload token", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "processing options", + "in": "body", + "name": "options", + "required": true, + "schema": { + "$ref": "#/definitions/form.UploadOptions" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "process previously uploaded files for a user", + "tags": [ + "Users", + "Files" + ] + } + }, + "/api/v1/videos/{hash}/{token}/{format}": { + "get": { + "description": "Fore more information see:\n- https://docs.photoprism.app/developer-guide/api/thumbnails/#video-endpoint-uri", + "operationId": "GetVideo", + "parameters": [ + { + "description": "SHA1 video file hash", + "in": "path", + "name": "thumb", + "required": true, + "type": "string" + }, + { + "description": "user-specific security token provided with session", + "in": "path", + "name": "token", + "required": true, + "type": "string" + }, + { + "description": "video format, e.g. mp4", + "in": "path", + "name": "format", + "required": true, + "type": "string" + } + ], + "produces": [ + "video/mp4" + ], + "responses": { + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns a video, optionally limited to a byte range for streaming", + "tags": [ + "Files", + "Videos" + ] + } + }, + "/api/v1/vision/caption": { + "post": { + "operationId": "PostVisionCaption", + "parameters": [ + { + "description": "list of image file urls", + "in": "body", + "name": "images", + "required": true, + "schema": { + "$ref": "#/definitions/vision.ApiRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vision.ApiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns a suitable caption for an image", + "tags": [ + "Vision" + ] + } + }, + "/api/v1/vision/face": { + "post": { + "operationId": "PostVisionFace", + "parameters": [ + { + "description": "list of image file urls", + "in": "body", + "name": "images", + "required": true, + "schema": { + "$ref": "#/definitions/vision.ApiRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vision.ApiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns the embeddings of a face image", + "tags": [ + "Vision" + ] + } + }, + "/api/v1/vision/labels": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "PostVisionLabels", + "parameters": [ + { + "description": "list of image file urls", + "in": "body", + "name": "images", + "required": true, + "schema": { + "$ref": "#/definitions/vision.ApiRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vision.ApiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns suitable labels for an image", + "tags": [ + "Vision" + ] + } + }, + "/api/v1/vision/nsfw": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "PostVisionNsfw", + "parameters": [ + { + "description": "list of image file urls", + "in": "body", + "name": "images", + "required": true, + "schema": { + "$ref": "#/definitions/vision.ApiRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/vision.ApiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "checks the specified images for inappropriate content", + "tags": [ + "Vision" + ] + } + }, + "/api/v1/zip": { + "post": { + "operationId": "ZipCreate", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "creates a zip file archive for download", + "tags": [ + "Download" + ] + } + }, + "/api/v1/zip/{filename}": { + "get": { + "operationId": "ZipDownload", + "parameters": [ + { + "description": "zip archive filename returned by the POST /api/v1/zip endpoint", + "in": "path", + "name": "filename", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/zip" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "returns a zip file archive after it has been created", + "tags": [ + "Download" + ] + } + }, + "/api/v1/{any}": { + "options": { + "description": "A preflight request is automatically issued by a browser and in normal cases, front-end developers don't need to craft such requests themselves. It appears when request is qualified as \"to be preflighted\" and omitted for simple requests.", + "operationId": "Options", + "responses": { + "204": { + "description": "No Content" + } + }, + "summary": "returns CORS headers with an empty response body", + "tags": [ + "CORS" + ] + } } }, "security": [ @@ -11938,8 +12155,12 @@ "BearerAuth": [] } ], - "externalDocs": { - "description": "Learn more ›", - "url": "https://docs.photoprism.app/developer-guide/api/" - } + "securityDefinitions": { + "BearerAuth": { + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + }, + "swagger": "2.0" } \ No newline at end of file diff --git a/internal/api/users_upload.go b/internal/api/users_upload.go index d33ac6fa0..011241c63 100644 --- a/internal/api/users_upload.go +++ b/internal/api/users_upload.go @@ -37,7 +37,7 @@ import ( // @Param files formData file true "one or more files to upload (repeat the field for multiple files)" // @Success 200 {object} i18n.Response // @Failure 400,401,403,413,429,507 {object} i18n.Response -// @Router /users/{uid}/upload/{token} [post] +// @Router /api/v1/users/{uid}/upload/{token} [post] func UploadUserFiles(router *gin.RouterGroup) { router.POST("/users/:uid/upload/:token", func(c *gin.Context) { conf := get.Config() @@ -273,7 +273,7 @@ func UploadCheckFile(destName string, rejectRaw bool, totalSizeLimit int64) (rem // @Param options body form.UploadOptions true "processing options" // @Success 200 {object} i18n.Response // @Failure 400,401,403,404,409,429 {object} i18n.Response -// @Router /users/{uid}/upload/{token} [put] +// @Router /api/v1/users/{uid}/upload/{token} [put] func ProcessUserUpload(router *gin.RouterGroup) { router.PUT("/users/:uid/upload/:token", func(c *gin.Context) { s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload}) diff --git a/internal/api/users_upload_multipart_test.go b/internal/api/users_upload_multipart_test.go new file mode 100644 index 000000000..41418d6fa --- /dev/null +++ b/internal/api/users_upload_multipart_test.go @@ -0,0 +1,677 @@ +package api + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/service/http/header" +) + +// buildMultipart builds a multipart form with one field name "files" and provided files. +func buildMultipart(files map[string][]byte) (body *bytes.Buffer, contentType string, err error) { + body = &bytes.Buffer{} + mw := multipart.NewWriter(body) + for name, data := range files { + fw, cerr := mw.CreateFormFile("files", name) + if cerr != nil { + return nil, "", cerr + } + if _, werr := fw.Write(data); werr != nil { + return nil, "", werr + } + } + cerr := mw.Close() + return body, mw.FormDataContentType(), cerr +} + +// buildMultipartTwo builds a multipart form with exactly two files (same field name: "files"). +func buildMultipartTwo(name1 string, data1 []byte, name2 string, data2 []byte) (body *bytes.Buffer, contentType string, err error) { + body = &bytes.Buffer{} + mw := multipart.NewWriter(body) + for _, it := range [][2]interface{}{{name1, data1}, {name2, data2}} { + fw, cerr := mw.CreateFormFile("files", it[0].(string)) + if cerr != nil { + return nil, "", cerr + } + if _, werr := fw.Write(it[1].([]byte)); werr != nil { + return nil, "", werr + } + } + cerr := mw.Close() + return body, mw.FormDataContentType(), cerr +} + +// buildZipWithDirsAndFiles creates a zip archive bytes with explicit directory entries and files. +func buildZipWithDirsAndFiles(dirs []string, files map[string][]byte) []byte { + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + // Directories (ensure trailing slash) + for _, d := range dirs { + name := d + if !strings.HasSuffix(name, "/") { + name += "/" + } + _, _ = zw.Create(name) + } + // Files + for name, data := range files { + f, _ := zw.Create(name) + _, _ = f.Write(data) + } + _ = zw.Close() + return zbuf.Bytes() +} + +func findUploadedFiles(t *testing.T, base string) []string { + t.Helper() + var out []string + _ = filepath.Walk(base, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + out = append(out, path) + } + return nil + }) + return out +} + +// findUploadedFilesForToken lists files only under upload subfolders whose name ends with token suffix. +func findUploadedFilesForToken(t *testing.T, base string, tokenSuffix string) []string { + t.Helper() + var out []string + entries, _ := os.ReadDir(base) + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, tokenSuffix) { + continue + } + dir := filepath.Join(base, name) + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + out = append(out, p) + } + return nil + }) + } + return out +} + +// removeUploadDirsForToken removes upload subdirectories whose name ends with tokenSuffix. +func removeUploadDirsForToken(t *testing.T, base string, tokenSuffix string) { + t.Helper() + entries, _ := os.ReadDir(base) + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, tokenSuffix) { + _ = os.RemoveAll(filepath.Join(base, name)) + } + } +} + +func TestUploadUserFiles_Multipart_SingleJPEG(t *testing.T) { + app, router, conf := NewApiTest() + // Limit allowed upload extensions to ensure text files get rejected in tests + conf.Options().UploadAllow = "jpg" + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + // Cleanup: remove token-specific upload dir after test + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "abc123") + // Load a real tiny JPEG from testdata + jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg") + data, err := os.ReadFile(jpgPath) + if err != nil { + t.Skipf("missing example.jpg: %v", err) + } + + body, ctype, err := buildMultipart(map[string][]byte{"example.jpg": data}) + if err != nil { + t.Fatal(err) + } + + reqUrl := "/api/v1/users/" + adminUid + "/upload/abc123" + req := httptest.NewRequest(http.MethodPost, reqUrl, body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // Verify file written somewhere under users//upload/* + uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, uploadBase, "abc123") + // At least one file written + assert.NotEmpty(t, files) + // Expect the filename to appear somewhere + var found bool + for _, f := range files { + if strings.HasSuffix(f, "example.jpg") { + found = true + break + } + } + assert.True(t, found, "uploaded JPEG not found") +} + +func TestUploadUserFiles_Multipart_ZipExtract(t *testing.T) { + app, router, conf := NewApiTest() + // Allow archives and restrict allowed extensions to images + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,png,zip" + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + + // Cleanup after test + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "ziptok") + // Create an in-memory zip with one JPEG (valid) and one TXT (rejected) + jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg") + jpg, err := os.ReadFile(jpgPath) + if err != nil { + t.Skip("missing example.jpg") + } + + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + // add jpeg + jf, _ := zw.Create("a.jpg") + _, _ = jf.Write(jpg) + // add txt + tf, _ := zw.Create("note.txt") + _, _ = io.WriteString(tf, "hello") + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"upload.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + reqUrl := "/api/v1/users/" + adminUid + "/upload/zipoff" + req := httptest.NewRequest(http.MethodPost, reqUrl, body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, uploadBase, "zipoff") + // Expect extracted jpeg present and txt absent + var jpgFound, txtFound bool + for _, f := range files { + if strings.HasSuffix(f, "a.jpg") { + jpgFound = true + } + if strings.HasSuffix(f, "note.txt") { + txtFound = true + } + } + assert.True(t, jpgFound, "extracted jpeg not found") + assert.False(t, txtFound, "text file should be rejected") +} + +func TestUploadUserFiles_Multipart_ArchivesDisabled(t *testing.T) { + app, router, conf := NewApiTest() + // disallow archives while allowing the .zip extension in filter + conf.Options().UploadArchives = false + conf.Options().UploadAllow = "jpg,zip" + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + + // Cleanup after test + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipoff") + // zip with one jpeg inside + jpgPath := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg") + jpg, err := os.ReadFile(jpgPath) + if err != nil { + t.Skip("missing example.jpg") + } + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + jf, _ := zw.Create("a.jpg") + _, _ = jf.Write(jpg) + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"upload.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + reqUrl := "/api/v1/users/" + adminUid + "/upload/ziptok" + req := httptest.NewRequest(http.MethodPost, reqUrl, body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + // server returns 200 even if rejected internally; nothing extracted/saved + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + uploadBase := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, uploadBase, "ziptok") + assert.Empty(t, files, "no files should remain when archives disabled") +} + +func TestUploadUserFiles_Multipart_PerFileLimitExceeded(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadAllow = "jpg" + conf.Options().OriginalsLimit = 1 // 1 MiB per-file + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "size1") + + // Build a 2MiB dummy payload (not a real JPEG; that's fine for pre-save size check) + big := bytes.Repeat([]byte("A"), 2*1024*1024) + body, ctype, err := buildMultipart(map[string][]byte{"big.jpg": big}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/size1", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + // Ensure nothing saved + files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "size1") + assert.Empty(t, files) +} + +func TestUploadUserFiles_Multipart_TotalLimitExceeded(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadAllow = "jpg" + conf.Options().UploadLimit = 1 // 1 MiB total + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "total") + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + // build multipart with two images so sum > 1 MiB (2*~63KiB = ~126KiB) -> still <1MiB, so use 16 copies + // build two bigger bodies by concatenation + times := 9 + big1 := bytes.Repeat(data, times) + big2 := bytes.Repeat(data, times) + body, ctype, err := buildMultipartTwo("a.jpg", big1, "b.jpg", big2) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/total", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Expect at most one file saved (second should be rejected by total limit) + files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "total") + assert.LessOrEqual(t, len(files), 1) +} + +func TestUploadUserFiles_Multipart_ZipPartialExtraction(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadLimit = 1 // 1 MiB total + conf.Options().OriginalsLimit = 50 // 50 MiB per file + conf.Options().UploadNSFW = true // skip nsfw scanning to speed up test + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "partial") + + // Build a zip containing multiple JPEG entries so that total extracted size > 1 MiB + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + for i := 0; i < 20; i++ { // ~20 * 63 KiB ≈ 1.2 MiB + f, _ := zw.Create(fmt.Sprintf("pic%02d.jpg", i+1)) + _, _ = f.Write(data) + } + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"multi.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/partial", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "partial") + // At least one extracted, but not all 20 due to total limit + var countJPG int + for _, f := range files { + if strings.HasSuffix(f, ".jpg") { + countJPG++ + } + } + assert.GreaterOrEqual(t, countJPG, 1) + assert.Less(t, countJPG, 20) +} + +func TestUploadUserFiles_Multipart_ZipDeepNestingStress(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadNSFW = true + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipdeep") + + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + // Build a deeply nested path (20 levels) + deep := "" + for i := 0; i < 20; i++ { + if i == 0 { + deep = "deep" + } else { + deep = filepath.Join(deep, fmt.Sprintf("lvl%02d", i)) + } + } + name := filepath.Join(deep, "deep.jpg") + zbytes := buildZipWithDirsAndFiles(nil, map[string][]byte{name: data}) + + body, ctype, err := buildMultipart(map[string][]byte{"deepnest.zip": zbytes}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipdeep", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + base := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, base, "zipdeep") + // Only one file expected, deep path created + assert.Equal(t, 1, len(files)) + assert.True(t, strings.Contains(files[0], filepath.Join("deep", "lvl01"))) +} + +func TestUploadUserFiles_Multipart_ZipRejectsHiddenAndTraversal(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadNSFW = true // skip scanning + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "rejects") + + // Prepare a valid jpg payload + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + // Hidden file + f1, _ := zw.Create(".hidden.jpg") + _, _ = f1.Write(data) + // @ file + f2, _ := zw.Create("@meta.jpg") + _, _ = f2.Write(data) + // Traversal path (will be skipped by safe join in unzip) + f3, _ := zw.Create("dir/../traverse.jpg") + _, _ = f3.Write(data) + // Valid file + f4, _ := zw.Create("ok.jpg") + _, _ = f4.Write(data) + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"test.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/rejects", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + files := findUploadedFilesForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "rejects") + var hasOk, hasHidden, hasAt, hasTraverse bool + for _, f := range files { + if strings.HasSuffix(f, "ok.jpg") { + hasOk = true + } + if strings.HasSuffix(f, ".hidden.jpg") { + hasHidden = true + } + if strings.HasSuffix(f, "@meta.jpg") { + hasAt = true + } + if strings.HasSuffix(f, "traverse.jpg") { + hasTraverse = true + } + } + assert.True(t, hasOk) + assert.False(t, hasHidden) + assert.False(t, hasAt) + assert.False(t, hasTraverse) +} + +func TestUploadUserFiles_Multipart_ZipNestedDirectories(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadNSFW = true + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipnest") + + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + // Create nested dirs and files + dirs := []string{"nested", "nested/sub"} + files := map[string][]byte{ + "nested/a.jpg": data, + "nested/sub/b.jpg": data, + } + zbytes := buildZipWithDirsAndFiles(dirs, files) + + body, ctype, err := buildMultipart(map[string][]byte{"nested.zip": zbytes}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipnest", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + base := filepath.Join(conf.UserStoragePath(adminUid), "upload") + filesOut := findUploadedFilesForToken(t, base, "zipnest") + var haveA, haveB bool + for _, f := range filesOut { + if strings.HasSuffix(f, filepath.Join("nested", "a.jpg")) { + haveA = true + } + if strings.HasSuffix(f, filepath.Join("nested", "sub", "b.jpg")) { + haveB = true + } + } + assert.True(t, haveA) + assert.True(t, haveB) + // Directories exist + // Locate token dir + entries, _ := os.ReadDir(base) + var tokenDir string + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), "zipnest") { + tokenDir = filepath.Join(base, e.Name()) + break + } + } + if tokenDir != "" { + _, errA := os.Stat(filepath.Join(tokenDir, "nested")) + _, errB := os.Stat(filepath.Join(tokenDir, "nested", "sub")) + assert.NoError(t, errA) + assert.NoError(t, errB) + } else { + t.Fatalf("token dir not found under %s", base) + } +} + +func TestUploadUserFiles_Multipart_ZipImplicitDirectories(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadNSFW = true + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipimpl") + + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + // Create zip containing only files with nested paths (no explicit directory entries) + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + f1, _ := zw.Create(filepath.Join("nested", "a.jpg")) + _, _ = f1.Write(data) + f2, _ := zw.Create(filepath.Join("nested", "sub", "b.jpg")) + _, _ = f2.Write(data) + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"nested-files-only.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipimpl", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + base := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, base, "zipimpl") + var haveA, haveB bool + for _, f := range files { + if strings.HasSuffix(f, filepath.Join("nested", "a.jpg")) { + haveA = true + } + if strings.HasSuffix(f, filepath.Join("nested", "sub", "b.jpg")) { + haveB = true + } + } + assert.True(t, haveA) + assert.True(t, haveB) + // Confirm directories were implicitly created + entries, _ := os.ReadDir(base) + var tokenDir string + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), "zipimpl") { + tokenDir = filepath.Join(base, e.Name()) + break + } + } + if tokenDir == "" { + t.Fatalf("token dir not found under %s", base) + } + _, errA := os.Stat(filepath.Join(tokenDir, "nested")) + _, errB := os.Stat(filepath.Join(tokenDir, "nested", "sub")) + assert.NoError(t, errA) + assert.NoError(t, errB) +} + +func TestUploadUserFiles_Multipart_ZipAbsolutePathRejected(t *testing.T) { + app, router, conf := NewApiTest() + conf.Options().UploadArchives = true + conf.Options().UploadAllow = "jpg,zip" + conf.Options().UploadNSFW = true + UploadUserFiles(router) + token := AuthenticateAdmin(app, router) + + adminUid := entity.Admin.UserUID + defer removeUploadDirsForToken(t, filepath.Join(conf.UserStoragePath(adminUid), "upload"), "zipabs") + + data, err := os.ReadFile(filepath.Clean("../../pkg/fs/testdata/directory/example.jpg")) + if err != nil { + t.Skip("missing example.jpg") + } + + // Zip with an absolute path entry + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + f, _ := zw.Create("/abs.jpg") + _, _ = f.Write(data) + _ = zw.Close() + + body, ctype, err := buildMultipart(map[string][]byte{"abs.zip": zbuf.Bytes()}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/"+adminUid+"/upload/zipabs", body) + req.Header.Set("Content-Type", ctype) + header.SetAuthorization(req, token) + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // No files should be extracted/saved for this token + base := filepath.Join(conf.UserStoragePath(adminUid), "upload") + files := findUploadedFilesForToken(t, base, "zipabs") + assert.Empty(t, files) +} diff --git a/internal/api/users_upload_test.go b/internal/api/users_upload_test.go index 7d19fe387..e13ed94a0 100644 --- a/internal/api/users_upload_test.go +++ b/internal/api/users_upload_test.go @@ -3,6 +3,8 @@ package api import ( "fmt" "net/http" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -43,3 +45,64 @@ func TestUploadUserFiles(t *testing.T) { config.Options().FilesQuota = 0 }) } + +func TestUploadCheckFile_AcceptsAndReducesLimit(t *testing.T) { + dir := t.TempDir() + // Copy a small known-good JPEG test file from pkg/fs/testdata + src := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg") + dst := filepath.Join(dir, "example.jpg") + b, err := os.ReadFile(src) + if err != nil { + t.Skipf("skip if test asset not present: %v", err) + } + if err := os.WriteFile(dst, b, 0o600); err != nil { + t.Fatal(err) + } + + orig := int64(len(b)) + rem, err := UploadCheckFile(dst, false, orig+100) + assert.NoError(t, err) + assert.Equal(t, int64(100), rem) + // file remains + assert.FileExists(t, dst) +} + +func TestUploadCheckFile_TotalLimitReachedDeletes(t *testing.T) { + dir := t.TempDir() + // Make a tiny file + dst := filepath.Join(dir, "tiny.txt") + assert.NoError(t, os.WriteFile(dst, []byte("hello"), 0o600)) + // Very small total limit (0) → should remove file and error + _, err := UploadCheckFile(dst, false, 0) + assert.Error(t, err) + _, statErr := os.Stat(dst) + assert.True(t, os.IsNotExist(statErr), "file should be removed when limit reached") +} + +func TestUploadCheckFile_UnsupportedTypeDeletes(t *testing.T) { + dir := t.TempDir() + // Create a file with an unknown extension; should be rejected + dst := filepath.Join(dir, "unknown.xyz") + assert.NoError(t, os.WriteFile(dst, []byte("not-an-image"), 0o600)) + _, err := UploadCheckFile(dst, false, 1<<20) + assert.Error(t, err) + _, statErr := os.Stat(dst) + assert.True(t, os.IsNotExist(statErr), "unsupported file should be removed") +} + +func TestUploadCheckFile_SizeAccounting(t *testing.T) { + dir := t.TempDir() + // Use known-good JPEG + src := filepath.Clean("../../pkg/fs/testdata/directory/example.jpg") + data, err := os.ReadFile(src) + if err != nil { + t.Skip("asset missing; skip") + } + f := filepath.Join(dir, "a.jpg") + assert.NoError(t, os.WriteFile(f, data, 0o600)) + size := int64(len(data)) + // Set remaining limit to size+1 so it does not hit the removal branch (which triggers on <=0) + rem, err := UploadCheckFile(f, false, size+1) + assert.NoError(t, err) + assert.Equal(t, int64(1), rem) +} diff --git a/internal/server/webdav.go b/internal/server/webdav.go index 6fc9afaa0..7a0ee76d0 100644 --- a/internal/server/webdav.go +++ b/internal/server/webdav.go @@ -152,9 +152,24 @@ func WebDAVFileName(request *http.Request, router *gin.RouterGroup, conf *config // Determine the absolute file path based on the request URL and the configuration. switch basePath { case conf.BaseUri(WebDAVOriginals): - fileName = filepath.Join(conf.OriginalsPath(), strings.TrimPrefix(request.URL.Path, basePath)) + // Resolve the requested path safely under OriginalsPath. + rel := strings.TrimPrefix(request.URL.Path, basePath) + // Make relative if a leading slash remains after trimming the base. + rel = strings.TrimLeft(rel, "/\\") + if name, err := joinUnderBase(conf.OriginalsPath(), rel); err == nil { + fileName = name + } else { + return "" + } case conf.BaseUri(WebDAVImport): - fileName = filepath.Join(conf.ImportPath(), strings.TrimPrefix(request.URL.Path, basePath)) + // Resolve the requested path safely under ImportPath. + rel := strings.TrimPrefix(request.URL.Path, basePath) + rel = strings.TrimLeft(rel, "/\\") + if name, err := joinUnderBase(conf.ImportPath(), rel); err == nil { + fileName = name + } else { + return "" + } default: return "" } @@ -167,6 +182,27 @@ func WebDAVFileName(request *http.Request, router *gin.RouterGroup, conf *config return fileName } +// joinUnderBase joins a base directory with a relative name and ensures +// that the resulting path stays within the base directory. Absolute +// paths and Windows-style volume names are rejected. +func joinUnderBase(baseDir, rel string) (string, error) { + if rel == "" { + return "", fmt.Errorf("invalid path") + } + // Reject absolute or volume paths. + if filepath.IsAbs(rel) || filepath.VolumeName(rel) != "" { + return "", fmt.Errorf("invalid path: absolute or volume path not allowed") + } + cleaned := filepath.Clean(rel) + // Compose destination and verify it stays inside base. + dest := filepath.Join(baseDir, cleaned) + base := filepath.Clean(baseDir) + if dest != base && !strings.HasPrefix(dest, base+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid path: outside base directory") + } + return dest, nil +} + // WebDAVSetFavoriteFlag adds the favorite flag to files uploaded via WebDAV. func WebDAVSetFavoriteFlag(fileName string) { yamlName := fs.AbsPrefix(fileName, false) + fs.ExtYml diff --git a/internal/server/webdav_actions_test.go b/internal/server/webdav_actions_test.go new file mode 100644 index 000000000..bb12a24cc --- /dev/null +++ b/internal/server/webdav_actions_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWebDAVSetFavoriteFlag_CreatesYamlOnce(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "img.jpg") + assert.NoError(t, os.WriteFile(file, []byte("x"), 0o600)) + // First call creates YAML + WebDAVSetFavoriteFlag(file) + // YAML is written next to file without the media extension (AbsPrefix) + yml := filepath.Join(filepath.Dir(file), "img.yml") + assert.FileExists(t, yml) + // Write a marker and ensure second call doesn't overwrite content + orig, _ := os.ReadFile(yml) + WebDAVSetFavoriteFlag(file) + now, _ := os.ReadFile(yml) + assert.Equal(t, string(orig), string(now)) +} + +func TestWebDAVSetFileMtime_NoFuture(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "a.txt") + assert.NoError(t, os.WriteFile(file, []byte("x"), 0o600)) + // Set a past mtime + WebDAVSetFileMtime(file, 946684800) // 2000-01-01 UTC + after, _ := os.Stat(file) + // Compare seconds to avoid platform-specific rounding + got := after.ModTime().Unix() + assert.Equal(t, int64(946684800), got) +} diff --git a/internal/server/webdav_secure_test.go b/internal/server/webdav_secure_test.go new file mode 100644 index 000000000..1cb08b0cc --- /dev/null +++ b/internal/server/webdav_secure_test.go @@ -0,0 +1,90 @@ +package server + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/pkg/fs" +) + +func TestJoinUnderBase(t *testing.T) { + base := t.TempDir() + // Normal join + out, err := joinUnderBase(base, "a/b/c.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(base, "a/b/c.txt"), out) + // Absolute rejected + _, err = joinUnderBase(base, "/etc/passwd") + assert.Error(t, err) + // Parent traversal rejected + _, err = joinUnderBase(base, "../../etc/passwd") + assert.Error(t, err) +} + +func TestWebDAVFileName_PathTraversalRejected(t *testing.T) { + dir := t.TempDir() + // Create a legitimate file inside base to ensure happy-path works later. + insideFile := filepath.Join(dir, "ok.txt") + assert.NoError(t, fs.WriteString(insideFile, "ok")) + + conf := config.NewTestConfig("server-webdav") + conf.Options().OriginalsPath = dir + + r := gin.New() + grp := r.Group(conf.BaseUri(WebDAVOriginals)) + + // Attempt traversal to outside path. + req := &http.Request{Method: http.MethodPut} + req.URL = &url.URL{Path: conf.BaseUri(WebDAVOriginals) + "/../../etc/passwd"} + got := WebDAVFileName(req, grp, conf) + assert.Equal(t, "", got, "should reject traversal") + + // Happy path: file under base resolves and exists. + req2 := &http.Request{Method: http.MethodPut} + req2.URL = &url.URL{Path: conf.BaseUri(WebDAVOriginals) + "/ok.txt"} + got = WebDAVFileName(req2, grp, conf) + assert.Equal(t, insideFile, got) +} + +func TestWebDAVFileName_MethodNotPut(t *testing.T) { + conf := config.NewTestConfig("server-webdav") + r := gin.New() + grp := r.Group(conf.BaseUri(WebDAVOriginals)) + req := &http.Request{Method: http.MethodGet} + req.URL = &url.URL{Path: conf.BaseUri(WebDAVOriginals) + "/anything.jpg"} + got := WebDAVFileName(req, grp, conf) + assert.Equal(t, "", got) +} + +func TestWebDAVFileName_ImportBasePath(t *testing.T) { + conf := config.NewTestConfig("server-webdav") + r := gin.New() + grp := r.Group(conf.BaseUri(WebDAVImport)) + // create a real file under import + file := filepath.Join(conf.ImportPath(), "in.jpg") + assert.NoError(t, fs.MkdirAll(filepath.Dir(file))) + assert.NoError(t, fs.WriteString(file, "x")) + req := &http.Request{Method: http.MethodPut} + req.URL = &url.URL{Path: conf.BaseUri(WebDAVImport) + "/in.jpg"} + got := WebDAVFileName(req, grp, conf) + assert.Equal(t, file, got) +} + +func TestWebDAVSetFileMtime_FutureIgnored(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "a.txt") + assert.NoError(t, fs.WriteString(file, "x")) + before, _ := os.Stat(file) + future := time.Now().Add(2 * time.Hour).Unix() + WebDAVSetFileMtime(file, future) + after, _ := os.Stat(file) + assert.Equal(t, before.ModTime().Unix(), after.ModTime().Unix()) +} diff --git a/internal/server/webdav_write_test.go b/internal/server/webdav_write_test.go new file mode 100644 index 000000000..54b20a4c0 --- /dev/null +++ b/internal/server/webdav_write_test.go @@ -0,0 +1,298 @@ +package server + +import ( + "bytes" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/service/http/header" +) + +func setupWebDAVRouter(conf *config.Config) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + grp := r.Group(conf.BaseUri(WebDAVOriginals), WebDAVAuth(conf)) + WebDAV(conf.OriginalsPath(), grp, conf) + return r +} + +func authBearer(req *http.Request) { + sess := entity.SessionFixtures.Get("alice_token_webdav") + header.SetAuthorization(req, sess.AuthToken()) +} + +func authBasic(req *http.Request) { + sess := entity.SessionFixtures.Get("alice_token_webdav") + basic := []byte(fmt.Sprintf("alice:%s", sess.AuthToken())) + req.Header.Set(header.Auth, fmt.Sprintf("%s %s", header.AuthBasic, base64.StdEncoding.EncodeToString(basic))) +} + +func TestWebDAVWrite_MKCOL_PUT(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + + // MKCOL + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodMkcol, conf.BaseUri(WebDAVOriginals)+"/wdvdir", nil) + authBearer(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) // Created + // PUT file + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodPut, conf.BaseUri(WebDAVOriginals)+"/wdvdir/hello.txt", bytes.NewBufferString("hello")) + authBearer(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + // file exists + path := filepath.Join(conf.OriginalsPath(), "wdvdir", "hello.txt") + b, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, "hello", string(b)) +} + +func TestWebDAVWrite_MOVE_COPY(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + + // Ensure source and destination directories via MKCOL + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodMkcol, conf.BaseUri(WebDAVOriginals)+"/src", nil) + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodMkcol, conf.BaseUri(WebDAVOriginals)+"/dst", nil) + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + // Create source file via PUT + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodPut, conf.BaseUri(WebDAVOriginals)+"/src/a.txt", bytes.NewBufferString("A")) + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + + // MOVE /originals/src/a.txt -> /originals/dst/b.txt + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/src/a.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/b.txt") + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + // Verify moved + assert.NoFileExists(t, filepath.Join(conf.OriginalsPath(), "src", "a.txt")) + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "dst", "b.txt")) + + // COPY /originals/dst/b.txt -> /originals/dst/c.txt + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodCopy, conf.BaseUri(WebDAVOriginals)+"/dst/b.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/c.txt") + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + // Verify copy + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "dst", "b.txt")) + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "dst", "c.txt")) +} + +func TestWebDAVWrite_OverwriteSemantics(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + + // Prepare src and dst + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "src"), 0o700) + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "dst"), 0o700) + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "src", "f.txt"), []byte("NEW"), 0o600) + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "dst", "f.txt"), []byte("OLD"), 0o600) + + // COPY with Overwrite: F -> should not overwrite existing + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodCopy, conf.BaseUri(WebDAVOriginals)+"/src/f.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/f.txt") + req.Header.Set("Overwrite", "F") + authBasic(req) + r.ServeHTTP(w, req) + // Expect not successful (commonly 412 Precondition Failed) + if w.Code == 201 || w.Code == 204 { + t.Fatalf("expected failure when Overwrite=F, got %d", w.Code) + } + // Content remains OLD + b, _ := os.ReadFile(filepath.Join(conf.OriginalsPath(), "dst", "f.txt")) + assert.Equal(t, "OLD", string(b)) + + // COPY with Overwrite: T -> must overwrite + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodCopy, conf.BaseUri(WebDAVOriginals)+"/src/f.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/f.txt") + req.Header.Set("Overwrite", "T") + authBasic(req) + r.ServeHTTP(w, req) + // Success (201/204 acceptable) + if !(w.Code == 201 || w.Code == 204) { + t.Fatalf("expected success for Overwrite=T, got %d", w.Code) + } + b, _ = os.ReadFile(filepath.Join(conf.OriginalsPath(), "dst", "f.txt")) + assert.Equal(t, "NEW", string(b)) + + // MOVE with Overwrite: F to existing file -> expect failure + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "src", "g.txt"), []byte("GNEW"), 0o600) + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "dst", "g.txt"), []byte("GOLD"), 0o600) + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/src/g.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/g.txt") + req.Header.Set("Overwrite", "F") + authBasic(req) + r.ServeHTTP(w, req) + if w.Code == 201 || w.Code == 204 { + t.Fatalf("expected failure when Overwrite=F for MOVE, got %d", w.Code) + } + // MOVE with Overwrite: T -> overwrites and removes source + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/src/g.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst/g.txt") + req.Header.Set("Overwrite", "T") + authBasic(req) + r.ServeHTTP(w, req) + if !(w.Code == 201 || w.Code == 204) { + t.Fatalf("expected success for MOVE Overwrite=T, got %d", w.Code) + } + assert.NoFileExists(t, filepath.Join(conf.OriginalsPath(), "src", "g.txt")) + gb, _ := os.ReadFile(filepath.Join(conf.OriginalsPath(), "dst", "g.txt")) + assert.Equal(t, "GNEW", string(gb)) +} + +func TestWebDAVWrite_MoveMissingDestination(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + // Ensure src exists + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "mv"), 0o700) + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "mv", "file.txt"), []byte("X"), 0o600) + + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/mv/file.txt", nil) + // no Destination header + authBasic(req) + r.ServeHTTP(w, req) + // Expect failure (not 201/204) + if w.Code == 201 || w.Code == 204 { + t.Fatalf("expected failure when Destination header missing, got %d", w.Code) + } + // Source remains + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "mv", "file.txt")) +} + +func TestWebDAVWrite_CopyInvalidDestinationPrefix(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + // Ensure src exists + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "cp"), 0o700) + _ = os.WriteFile(filepath.Join(conf.OriginalsPath(), "cp", "a.txt"), []byte("A"), 0o600) + + // COPY to a destination outside the handler prefix + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodCopy, conf.BaseUri(WebDAVOriginals)+"/cp/a.txt", nil) + req.Header.Set("Destination", "/notwebdav/d.txt") + authBasic(req) + r.ServeHTTP(w, req) + // Expect failure + if w.Code == 201 || w.Code == 204 { + t.Fatalf("expected failure for invalid Destination prefix, got %d", w.Code) + } + // Destination not created + assert.NoFileExists(t, filepath.Join(conf.OriginalsPath(), "notwebdav", "d.txt")) +} + +func TestWebDAVWrite_MoveNonExistentSource(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + // Ensure destination dir exists + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "dst2"), 0o700) + + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/nosuch/file.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/dst2/file.txt") + authBasic(req) + r.ServeHTTP(w, req) + // Expect failure (e.g., 404) + if w.Code == 201 || w.Code == 204 { + t.Fatalf("expected failure moving non-existent source, got %d", w.Code) + } + assert.NoFileExists(t, filepath.Join(conf.OriginalsPath(), "dst2", "file.txt")) +} + +func TestWebDAVWrite_CopyTraversalDestination(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + + // Create source file via PUT + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "travsrc"), 0o700) + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodPut, conf.BaseUri(WebDAVOriginals)+"/travsrc/a.txt", bytes.NewBufferString("A")) + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + + // Attempt COPY with traversal in Destination + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodCopy, conf.BaseUri(WebDAVOriginals)+"/travsrc/a.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/../evil.txt") + authBasic(req) + r.ServeHTTP(w, req) + // Expect success with sanitized destination inside base + if !(w.Code == 201 || w.Code == 204) { + t.Fatalf("expected success (sanitized), got %d", w.Code) + } + // Not created above originals; created as /originals/evil.txt + parent := filepath.Dir(conf.OriginalsPath()) + assert.NoFileExists(t, filepath.Join(parent, "evil.txt")) + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "evil.txt")) +} + +func TestWebDAVWrite_MoveTraversalDestination(t *testing.T) { + conf := config.TestConfig() + r := setupWebDAVRouter(conf) + + // Create source file via PUT + _ = os.MkdirAll(filepath.Join(conf.OriginalsPath(), "travsrc2"), 0o700) + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodPut, conf.BaseUri(WebDAVOriginals)+"/travsrc2/a.txt", bytes.NewBufferString("A")) + authBasic(req) + r.ServeHTTP(w, req) + assert.InDelta(t, 201, w.Code, 1) + + // Attempt MOVE with traversal in Destination + w = httptest.NewRecorder() + req = httptest.NewRequest(MethodMove, conf.BaseUri(WebDAVOriginals)+"/travsrc2/a.txt", nil) + req.Header.Set("Destination", conf.BaseUri(WebDAVOriginals)+"/../evil2.txt") + authBasic(req) + r.ServeHTTP(w, req) + if !(w.Code == 201 || w.Code == 204) { + t.Fatalf("expected success (sanitized) for MOVE, got %d", w.Code) + } + // Source removed; destination created inside base, not outside + assert.NoFileExists(t, filepath.Join(conf.OriginalsPath(), "travsrc2", "a.txt")) + parent := filepath.Dir(conf.OriginalsPath()) + assert.NoFileExists(t, filepath.Join(parent, "evil2.txt")) + assert.FileExists(t, filepath.Join(conf.OriginalsPath(), "evil2.txt")) +} + +func TestWebDAVWrite_ReadOnlyForbidden(t *testing.T) { + conf := config.TestConfig() + conf.Options().ReadOnly = true + r := setupWebDAVRouter(conf) + w := httptest.NewRecorder() + req := httptest.NewRequest(MethodMkcol, conf.BaseUri(WebDAVOriginals)+"/ro", nil) + authBearer(req) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/internal/thumb/avatar/download.go b/internal/thumb/avatar/download.go new file mode 100644 index 000000000..b110dff1d --- /dev/null +++ b/internal/thumb/avatar/download.go @@ -0,0 +1,42 @@ +package avatar + +import ( + "strings" + "time" + + "github.com/photoprism/photoprism/pkg/service/http/safe" +) + +var ( + // Stricter defaults for avatar images than the generic HTTP safe defaults. + defaultTimeout = 15 * time.Second + defaultMaxSize int64 = 10 << 20 // 10 MiB for avatar images +) + +// SafeDownload delegates avatar image downloads to the shared HTTP safe downloader +// with hardened defaults suitable for small image files. +// Callers may pass a partially filled safe.Options to override defaults. +func SafeDownload(destPath, rawURL string, opt *safe.Options) error { + // Start with strict avatar defaults. + o := &safe.Options{ + Timeout: defaultTimeout, + MaxSizeBytes: defaultMaxSize, + AllowPrivate: false, // block private/loopback by default + // Prefer images but allow others at low priority; MIME is validated later. + Accept: "image/jpeg, image/png, */*;q=0.1", + } + if opt != nil { + if opt.Timeout > 0 { + o.Timeout = opt.Timeout + } + if opt.MaxSizeBytes > 0 { + o.MaxSizeBytes = opt.MaxSizeBytes + } + // Bool has no sentinel; just copy the value. + o.AllowPrivate = opt.AllowPrivate + if strings.TrimSpace(opt.Accept) != "" { + o.Accept = opt.Accept + } + } + return safe.Download(destPath, rawURL, o) +} diff --git a/internal/thumb/avatar/download_test.go b/internal/thumb/avatar/download_test.go new file mode 100644 index 000000000..28537de32 --- /dev/null +++ b/internal/thumb/avatar/download_test.go @@ -0,0 +1,73 @@ +package avatar + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/photoprism/photoprism/pkg/service/http/safe" +) + +func TestSafeDownload_InvalidScheme(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "x") + if err := SafeDownload(dest, "file:///etc/passwd", nil); err == nil { + t.Fatal("expected error for invalid scheme") + } +} + +func TestSafeDownload_PrivateIPBlocked(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "x") + if err := SafeDownload(dest, "http://127.0.0.1/test.png", nil); err == nil { + t.Fatal("expected SSRF private IP block") + } +} + +func TestSafeDownload_MaxSizeExceeded(t *testing.T) { + // Local server; allow private for test. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + // 2MB body + w.WriteHeader(http.StatusOK) + buf := make([]byte, 2<<20) + _, _ = w.Write(buf) + })) + defer ts.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "big") + err := SafeDownload(dest, ts.URL, &safe.Options{Timeout: 5 * time.Second, MaxSizeBytes: 1 << 20, AllowPrivate: true}) + if err == nil { + t.Fatal("expected size exceeded error") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Fatalf("expected no output file on error, got stat err=%v", statErr) + } +} + +func TestSafeDownload_Succeeds(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer ts.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "ok") + if err := SafeDownload(dest, ts.URL, &safe.Options{Timeout: 5 * time.Second, MaxSizeBytes: 1 << 20, AllowPrivate: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(b) != "ok" { + t.Fatalf("unexpected content: %q", string(b)) + } +} diff --git a/internal/thumb/avatar/user.go b/internal/thumb/avatar/user.go index 61ec8d08a..a1c0a2c07 100644 --- a/internal/thumb/avatar/user.go +++ b/internal/thumb/avatar/user.go @@ -32,7 +32,8 @@ func SetUserImageURL(m *entity.User, imageUrl, imageSrc, thumbPath string) error tmpName := filepath.Join(os.TempDir(), rnd.Base36(64)) - if err = fs.Download(tmpName, u.String()); err != nil { + // Hardened remote fetch with SSRF and size limits. + if err = SafeDownload(tmpName, u.String(), nil); err != nil { return fmt.Errorf("failed to download avatar image (%w)", err) } diff --git a/pkg/fs/fs.go b/pkg/fs/fs.go index c23cddebe..33b2af379 100644 --- a/pkg/fs/fs.go +++ b/pkg/fs/fs.go @@ -27,11 +27,12 @@ package fs import ( "fmt" "io" - "net/http" "os" "os/user" "path/filepath" "syscall" + + "github.com/photoprism/photoprism/pkg/service/http/safe" ) var ignoreCase bool @@ -206,40 +207,9 @@ func Abs(name string) string { // Download downloads a file from a URL. func Download(fileName string, url string) error { - if dir := filepath.Dir(fileName); dir == "" || dir == "/" || dir == "." || dir == ".." { - return fmt.Errorf("invalid path") - } else if err := MkdirAll(dir); err != nil { - return err - } - - // Create the file - out, err := os.Create(fileName) - if err != nil { - return err - } - - defer out.Close() - - // Get the data - resp, err := http.Get(url) - if err != nil { - return err - } - - defer resp.Body.Close() - - // Check server response - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bad status: %s", resp.Status) - } - - // Writer the body to file - _, err = io.Copy(out, resp.Body) - if err != nil { - return err - } - - return nil + // Preserve existing semantics but with safer network behavior. + // Allow private IPs by default to avoid breaking intended internal downloads. + return safe.Download(fileName, url, &safe.Options{AllowPrivate: true}) } // DirIsEmpty returns true if a directory is empty. diff --git a/pkg/service/http/safe/download.go b/pkg/service/http/safe/download.go new file mode 100644 index 000000000..9897b159d --- /dev/null +++ b/pkg/service/http/safe/download.go @@ -0,0 +1,218 @@ +package safe + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Download fetches a URL to a destination file with timeouts, size limits, and optional SSRF protection. +func Download(destPath, rawURL string, opt *Options) error { + if destPath == "" { + return errors.New("invalid destination path") + } + // Prepare destination directory. + if dir := filepath.Dir(destPath); dir == "" || dir == "/" || dir == "." || dir == ".." { + return errors.New("invalid destination directory") + } else if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + u, err := url.Parse(rawURL) + if err != nil { + return err + } + if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + return ErrSchemeNotAllowed + } + + // Defaults w/ env overrides + maxSize := defaultMaxSize + if n := envInt64("PHOTOPRISM_HTTP_MAX_DOWNLOAD"); n > 0 { + maxSize = n + } + timeout := defaultTimeout + if d := envDuration("PHOTOPRISM_HTTP_TIMEOUT"); d > 0 { + timeout = d + } + + o := Options{Timeout: timeout, MaxSizeBytes: maxSize, AllowPrivate: true, Accept: "*/*"} + if opt != nil { + if opt.Timeout > 0 { + o.Timeout = opt.Timeout + } + if opt.MaxSizeBytes > 0 { + o.MaxSizeBytes = opt.MaxSizeBytes + } + o.AllowPrivate = opt.AllowPrivate + if strings.TrimSpace(opt.Accept) != "" { + o.Accept = opt.Accept + } + } + + // Optional SSRF block + if !o.AllowPrivate { + if ip := net.ParseIP(u.Hostname()); ip != nil { + if isPrivateOrDisallowedIP(ip) { + return ErrPrivateIP + } + } else { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + addrs, lookErr := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname()) + if lookErr != nil { + return lookErr + } + for _, a := range addrs { + if isPrivateOrDisallowedIP(a.IP) { + return ErrPrivateIP + } + } + } + } + + // Enforce redirect validation when private networks are disallowed. + client := &http.Client{ + Timeout: o.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if !o.AllowPrivate { + h := req.URL.Hostname() + if ip := net.ParseIP(h); ip != nil { + if isPrivateOrDisallowedIP(ip) { + return ErrPrivateIP + } + } else { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + addrs, lookErr := net.DefaultResolver.LookupIPAddr(ctx, h) + if lookErr != nil { + return lookErr + } + for _, a := range addrs { + if isPrivateOrDisallowedIP(a.IP) { + return ErrPrivateIP + } + } + } + } + // Propagate Accept header from the first request. + if len(via) > 0 { + if v := via[0].Header.Get("Accept"); v != "" { + req.Header.Set("Accept", v) + } + } + return nil + }, + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return err + } + if o.Accept != "" { + req.Header.Set("Accept", o.Accept) + } + // Capture the final remote IP used for the connection. + var finalIP net.IP + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if addr := info.Conn.RemoteAddr(); addr != nil { + host, _, _ := net.SplitHostPort(addr.String()) + if ip := net.ParseIP(host); ip != nil { + finalIP = ip + } + } + }, + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + // Validate the connected peer address when private ranges are disallowed. + if !o.AllowPrivate && finalIP != nil && isPrivateOrDisallowedIP(finalIP) { + return ErrPrivateIP + } + + if resp.ContentLength > 0 && o.MaxSizeBytes > 0 && resp.ContentLength > o.MaxSizeBytes { + return ErrSizeExceeded + } + + tmp := destPath + ".part" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + f.Close() + if err != nil { + _ = os.Remove(tmp) + } + }() + + var r io.Reader = resp.Body + if o.MaxSizeBytes > 0 { + r = io.LimitReader(resp.Body, o.MaxSizeBytes+1) + } + n, copyErr := io.Copy(f, r) + if copyErr != nil { + err = copyErr + return err + } + if o.MaxSizeBytes > 0 && n > o.MaxSizeBytes { + err = ErrSizeExceeded + return err + } + if err = f.Close(); err != nil { + return err + } + if err = os.Rename(tmp, destPath); err != nil { + return err + } + return nil +} + +func isPrivateOrDisallowedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + if v4 := ip.To4(); v4 != nil { + if v4[0] == 10 { + return true + } + if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 { + return true + } + if v4[0] == 192 && v4[1] == 168 { + return true + } + if v4[0] == 169 && v4[1] == 254 { + return true + } + return false + } + // IPv6 ULA fc00::/7 + if ip.To16() != nil { + if ip[0]&0xFE == 0xFC { + return true + } + } + return false +} diff --git a/pkg/service/http/safe/download_redirect_test.go b/pkg/service/http/safe/download_redirect_test.go new file mode 100644 index 000000000..2a124f351 --- /dev/null +++ b/pkg/service/http/safe/download_redirect_test.go @@ -0,0 +1,57 @@ +package safe + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// Redirect to a private IP must be blocked when AllowPrivate=false. +func TestDownload_BlockRedirectToPrivate(t *testing.T) { + // Public-looking server that redirects to 127.0.0.1 + redirectTarget := "http://127.0.0.1:65535/secret" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, redirectTarget, http.StatusFound) + })) + defer ts.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "out") + err := Download(dest, ts.URL, &Options{Timeout: 5 * time.Second, MaxSizeBytes: 1 << 20, AllowPrivate: false}) + if err == nil { + t.Fatalf("expected redirect SSRF to be blocked") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Fatalf("expected no output file on error, got stat err=%v", statErr) + } +} + +// With AllowPrivate=true, redirects to a local httptest server should succeed. +func TestDownload_AllowRedirectToPrivate(t *testing.T) { + // Local private target that serves content. + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer target.Close() + + // Public-looking server that redirects to the private target. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer ts.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "ok") + if err := Download(dest, ts.URL, &Options{Timeout: 5 * time.Second, MaxSizeBytes: 1 << 20, AllowPrivate: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := os.ReadFile(dest) + if err != nil || string(b) != "ok" { + t.Fatalf("unexpected content: %v %q", err, string(b)) + } +} diff --git a/pkg/service/http/safe/download_test.go b/pkg/service/http/safe/download_test.go new file mode 100644 index 000000000..7e1937828 --- /dev/null +++ b/pkg/service/http/safe/download_test.go @@ -0,0 +1,42 @@ +package safe + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSafeDownload_OK(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "hello") + })) + defer ts.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "ok.txt") + if err := Download(dest, ts.URL, &Options{Timeout: 5 * time.Second, MaxSizeBytes: 1024, AllowPrivate: true}); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(dest) + if err != nil || string(b) != "hello" { + t.Fatalf("unexpected content: %v %q", err, string(b)) + } +} + +func TestSafeDownload_TooLarge(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // 2KiB + _, _ = w.Write(make([]byte, 2048)) + })) + defer ts.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "big.bin") + if err := Download(dest, ts.URL, &Options{Timeout: 5 * time.Second, MaxSizeBytes: 1024, AllowPrivate: true}); err == nil { + t.Fatalf("expected ErrSizeExceeded") + } +} diff --git a/pkg/service/http/safe/options.go b/pkg/service/http/safe/options.go new file mode 100644 index 000000000..b9108a517 --- /dev/null +++ b/pkg/service/http/safe/options.go @@ -0,0 +1,47 @@ +package safe + +import ( + "errors" + "os" + "strconv" + "strings" + "time" +) + +// Options controls Download behavior. +type Options struct { + Timeout time.Duration + MaxSizeBytes int64 + AllowPrivate bool + Accept string +} + +var ( + // Defaults are tuned for general downloads (not just avatars). + defaultTimeout = 30 * time.Second + defaultMaxSize = int64(200 * 1024 * 1024) // 200 MiB + + ErrSchemeNotAllowed = errors.New("invalid scheme (only http/https allowed)") + ErrSizeExceeded = errors.New("response exceeds maximum allowed size") + ErrPrivateIP = errors.New("connection to private or loopback address not allowed") +) + +// envInt64 returns an int64 from env or -1 if unset/invalid. +func envInt64(key string) int64 { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + return -1 +} + +// envDuration returns a duration from env seconds or 0 if unset/invalid. +func envDuration(key string) time.Duration { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return time.Duration(n) * time.Second + } + } + return 0 +} diff --git a/pkg/service/http/safe/safe.go b/pkg/service/http/safe/safe.go new file mode 100644 index 000000000..587afe93a --- /dev/null +++ b/pkg/service/http/safe/safe.go @@ -0,0 +1,25 @@ +/* +Package safe provides a secure HTTP downloader with customizable settings. + +Copyright (c) 2018 - 2025 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"): + + + 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: + + +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: + +*/ +package safe diff --git a/scripts/tools/swaggerfix/main.go b/scripts/tools/swaggerfix/main.go new file mode 100644 index 000000000..96d5d58d5 --- /dev/null +++ b/scripts/tools/swaggerfix/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: swaggerfix ") + os.Exit(2) + } + path := os.Args[1] + b, err := ioutil.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, "read:", err) + os.Exit(1) + } + var doc map[string]interface{} + if err := json.Unmarshal(b, &doc); err != nil { + fmt.Fprintln(os.Stderr, "parse:", err) + os.Exit(1) + } + // Traverse to definitions.time.Duration + defs, _ := doc["definitions"].(map[string]interface{}) + if defs == nil { + fmt.Fprintln(os.Stderr, "no definitions in swagger file") + os.Exit(1) + } + td, _ := defs["time.Duration"].(map[string]interface{}) + if td == nil { + fmt.Fprintln(os.Stderr, "no time.Duration schema found; nothing to do") + os.Exit(0) + } + // Remove unstable enums and varnames to ensure deterministic output. + delete(td, "enum") + delete(td, "x-enum-varnames") + defs["time.Duration"] = td + doc["definitions"] = defs + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal:", err) + os.Exit(1) + } + if err := ioutil.WriteFile(path, out, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } +} +