From 4491c7513554171eb0c7eab406d7d5be25c31289 Mon Sep 17 00:00:00 2001 From: Lincoln Nogueira Date: Wed, 9 Aug 2023 10:53:06 -0300 Subject: [PATCH] feat: add SwaggerUI and v1 API docs (#2115) * - Refactor several API routes from anonymous functions to regular definitions. Required to add parseable documentation comments. - Add API documentation comments using Swag Declarative Comments Format - Add echo-swagger to serve Swagger-UI at /api/index.html - Fix error response from extraneous parameter resourceId to relatedMemoId in DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType") - Add an auto-generated ./docs/api/v1.md for quick reference on repo (generated by swagger-markdown) - Add auxiliary scripts to generate docs.go and swagger.yaml * fix: golangci-lint errors * fix: go fmt flag in swag scripts --- api/docs.go | 3405 ++++++++++++++++++++++++ api/swagger.yaml | 2248 ++++++++++++++++ api/v1/auth.go | 401 +-- api/v1/http_getter.go | 98 +- api/v1/idp.go | 348 ++- api/v1/memo.go | 947 ++++--- api/v1/memo_organizer.go | 115 +- api/v1/memo_relation.go | 155 +- api/v1/memo_resource.go | 220 +- api/v1/resource.go | 639 +++-- api/v1/rss.go | 123 +- api/v1/storage.go | 346 ++- api/v1/system.go | 232 +- api/v1/system_setting.go | 190 +- api/v1/tag.go | 259 +- api/v1/user.go | 636 +++-- api/v1/user_setting.go | 77 +- docs/api/v1.md | 1848 +++++++++++++ docs/documenting-the-api.md | 113 + go.mod | 36 +- go.sum | 91 +- scripts/generate-api-documentation.cfg | 13 + scripts/generate-api-documentation.ps1 | 73 + scripts/generate-api-documentation.sh | 108 + server/server.go | 27 + 25 files changed, 10689 insertions(+), 2059 deletions(-) create mode 100644 api/docs.go create mode 100644 api/swagger.yaml create mode 100644 docs/api/v1.md create mode 100644 docs/documenting-the-api.md create mode 100644 scripts/generate-api-documentation.cfg create mode 100644 scripts/generate-api-documentation.ps1 create mode 100755 scripts/generate-api-documentation.sh diff --git a/api/docs.go b/api/docs.go new file mode 100644 index 00000000..46237942 --- /dev/null +++ b/api/docs.go @@ -0,0 +1,3405 @@ +// Code generated by swaggo/swag. DO NOT EDIT. + +package api + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": { + "name": "API Support", + "url": "https://github.com/orgs/usememos/discussions" + }, + "license": { + "name": "MIT License", + "url": "https://github.com/usememos/memos/blob/main/LICENSE" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/auth/signin": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Sign-in to memos.", + "parameters": [ + { + "description": "Sign-in object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.SignIn" + } + } + ], + "responses": { + "200": { + "description": "User information", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Malformatted signin request" + }, + "401": { + "description": "Password login is deactivated | Incorrect login credentials, please try again" + }, + "403": { + "description": "User has been archived with username %s" + }, + "500": { + "description": "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity" + } + } + } + }, + "/api/v1/auth/signin/sso": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Sign-in to memos using SSO.", + "parameters": [ + { + "description": "SSO sign-in object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.SSOSignIn" + } + } + ], + "responses": { + "200": { + "description": "User information", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Malformatted signin request" + }, + "401": { + "description": "Access denied, identifier does not match the filter." + }, + "403": { + "description": "User has been archived with username {username}" + }, + "404": { + "description": "Identity provider not found" + }, + "500": { + "description": "Failed to find identity provider | Failed to create identity provider instance | Failed to exchange token | Failed to get user info | Failed to compile identifier filter | Incorrect login credentials, please try again | Failed to generate random password | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity" + } + } + } + }, + "/api/v1/auth/signout": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Sign-out from memos.", + "responses": { + "200": { + "description": "Sign-out success", + "schema": { + "type": "boolean" + } + } + } + } + }, + "/api/v1/auth/signup": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Sign-up to memos.", + "parameters": [ + { + "description": "Sign-up object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.SignUp" + } + } + ], + "responses": { + "200": { + "description": "User information", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Malformatted signup request | Failed to find users" + }, + "401": { + "description": "signup is disabled" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Failed to find system setting | Failed to unmarshal system setting allow signup | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity" + } + } + } + }, + "/api/v1/idp": { + "get": { + "description": "*clientSecret is only available for host user", + "produces": [ + "application/json" + ], + "tags": [ + "idp" + ], + "summary": "Get a list of identity providers", + "responses": { + "200": { + "description": "List of available identity providers", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.IdentityProvider" + } + } + }, + "500": { + "description": "Failed to find identity provider list | Failed to find user" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "idp" + ], + "summary": "Create Identity Provider", + "parameters": [ + { + "description": "Identity provider information", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.CreateIdentityProviderRequest" + } + } + ], + "responses": { + "200": { + "description": "Identity provider information", + "schema": { + "$ref": "#/definitions/store.IdentityProvider" + } + }, + "400": { + "description": "Malformatted post identity provider request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to create identity provider" + } + } + } + }, + "/api/v1/idp/{idpId}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "idp" + ], + "summary": "Get an identity provider by ID", + "parameters": [ + { + "type": "integer", + "description": "Identity provider ID", + "name": "idpId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Requested identity provider", + "schema": { + "$ref": "#/definitions/store.IdentityProvider" + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "404": { + "description": "Identity provider not found" + }, + "500": { + "description": "Failed to find identity provider list | Failed to find user" + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "idp" + ], + "summary": "Delete an identity provider by ID", + "parameters": [ + { + "type": "integer", + "description": "Identity Provider ID", + "name": "idpId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Identity Provider deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted patch identity provider request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to patch identity provider" + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "idp" + ], + "summary": "Update an identity provider by ID", + "parameters": [ + { + "type": "integer", + "description": "Identity Provider ID", + "name": "idpId", + "in": "path", + "required": true + }, + { + "description": "Patched identity provider information", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpdateIdentityProviderRequest" + } + } + ], + "responses": { + "200": { + "description": "Patched identity provider", + "schema": { + "$ref": "#/definitions/store.IdentityProvider" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted patch identity provider request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to patch identity provider" + } + } + } + }, + "/api/v1/memo": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Get a list of memos matching optional filters", + "parameters": [ + { + "type": "integer", + "description": "Creator ID", + "name": "creatorId", + "in": "query" + }, + { + "type": "string", + "description": "Creator username", + "name": "creatorUsername", + "in": "query" + }, + { + "enum": [ + "NORMAL", + "ARCHIVED" + ], + "type": "string", + "description": "Row status", + "name": "rowStatus", + "in": "query" + }, + { + "type": "boolean", + "description": "Pinned", + "name": "pinned", + "in": "query" + }, + { + "type": "string", + "description": "Search for tag. Do not append #", + "name": "tag", + "in": "query" + }, + { + "type": "string", + "description": "Search for content", + "name": "content", + "in": "query" + }, + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Memo list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Memo" + } + } + }, + "400": { + "description": "Missing user to find memo" + }, + "500": { + "description": "Failed to get memo display with updated ts setting value | Failed to fetch memo list | Failed to compose memo response" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Visibility can be PUBLIC, PROTECTED or PRIVATE\n*You should omit fields to use their default values", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Create a memo", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.CreateMemoRequest" + } + } + ], + "responses": { + "200": { + "description": "Stored memo", + "schema": { + "$ref": "#/definitions/store.Memo" + } + }, + "400": { + "description": "Malformatted post memo request | Content size overflow, up to 1MB" + }, + "401": { + "description": "Missing user in session" + }, + "404": { + "description": "User not found | Memo not found: %d" + }, + "500": { + "description": "Failed to find user setting | Failed to unmarshal user setting value | Failed to find system setting | Failed to unmarshal system setting | Failed to find user | Failed to create memo | Failed to create activity | Failed to upsert memo resource | Failed to upsert memo relation | Failed to compose memo | Failed to compose memo response" + } + } + } + }, + "/api/v1/memo/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This should also list protected memos if the user is logged in\nAuthentication is optional", + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Get a list of public memos matching optional filters", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Memo list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Memo" + } + } + }, + "500": { + "description": "Failed to get memo display with updated ts setting value | Failed to fetch all memo list | Failed to compose memo response" + } + } + } + }, + "/api/v1/memo/stats": { + "get": { + "description": "Used to generate the heatmap", + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Get memo stats by creator ID or username", + "parameters": [ + { + "type": "integer", + "description": "Creator ID", + "name": "creatorId", + "in": "query" + }, + { + "type": "string", + "description": "Creator username", + "name": "creatorUsername", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Memo createdTs list", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "400": { + "description": "Missing user id to find memo" + }, + "500": { + "description": "Failed to get memo display with updated ts setting value | Failed to find memo list | Failed to compose memo response" + } + } + } + }, + "/api/v1/memo/{memoId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Get memo by ID", + "parameters": [ + { + "type": "integer", + "description": "Memo ID", + "name": "memoId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Memo" + } + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "401": { + "description": "Missing user in session" + }, + "403": { + "description": "this memo is private only | this memo is protected, missing user in session" + }, + "404": { + "description": "Memo not found: %d" + }, + "500": { + "description": "Failed to find memo by ID: %v | Failed to compose memo response" + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Delete memo by ID", + "parameters": [ + { + "type": "integer", + "description": "Memo ID to delete", + "name": "memoId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "404": { + "description": "Memo not found: %d" + }, + "500": { + "description": "Failed to find memo | Failed to delete memo ID: %v" + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Visibility can be PUBLIC, PROTECTED or PRIVATE\n*You should omit fields to use their default values", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo" + ], + "summary": "Update a memo", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to update", + "name": "memoId", + "in": "path", + "required": true + }, + { + "description": "Patched object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.PatchMemoRequest" + } + } + ], + "responses": { + "200": { + "description": "Stored memo", + "schema": { + "$ref": "#/definitions/store.Memo" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted patch memo request | Content size overflow, up to 1MB" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "404": { + "description": "Memo not found: %d" + }, + "500": { + "description": "Failed to find memo | Failed to patch memo | Failed to upsert memo resource | Failed to delete memo resource | Failed to compose memo response" + } + } + } + }, + "/api/v1/memo/{memoId}/organizer": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-organizer" + ], + "summary": "Organize memo (pin/unpin)", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to organize", + "name": "memoId", + "in": "path", + "required": true + }, + { + "description": "Memo organizer object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertMemoOrganizerRequest" + } + } + ], + "responses": { + "200": { + "description": "Memo information", + "schema": { + "$ref": "#/definitions/store.Memo" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted post memo organizer request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "404": { + "description": "Memo not found: %v" + }, + "500": { + "description": "Failed to find memo | Failed to upsert memo organizer | Failed to find memo by ID: %v | Failed to compose memo response" + } + } + } + }, + "/api/v1/memo/{memoId}/relation": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-relation" + ], + "summary": "Get a list of Memo Relations", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to find relations", + "name": "memoId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo relation information list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.MemoRelation" + } + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "500": { + "description": "Failed to list memo relations" + } + } + }, + "post": { + "description": "Create a relation between two memos", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-relation" + ], + "summary": "Create Memo Relation", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to relate", + "name": "memoId", + "in": "path", + "required": true + }, + { + "description": "Memo relation object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertMemoRelationRequest" + } + } + ], + "responses": { + "200": { + "description": "Memo relation information", + "schema": { + "$ref": "#/definitions/store.MemoRelation" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted post memo relation request" + }, + "500": { + "description": "Failed to upsert memo relation" + } + } + } + }, + "/api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType}": { + "delete": { + "description": "Removes a relation between two memos", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-relation" + ], + "summary": "Delete a Memo Relation", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to find relations", + "name": "memoId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of memo to remove relation to", + "name": "relatedMemoId", + "in": "path", + "required": true + }, + { + "enum": [ + "REFERENCE", + "ADDITIONAL" + ], + "type": "string", + "description": "Type of relation to remove", + "name": "relationType", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo relation deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "Memo ID is not a number: %s | Related memo ID is not a number: %s" + }, + "500": { + "description": "Failed to delete memo relation" + } + } + } + }, + "/api/v1/memo/{memoId}/resource": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-resource" + ], + "summary": "Get resource list of a memo", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to fetch resource list from", + "name": "memoId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo resource list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.Resource" + } + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "500": { + "description": "Failed to fetch resource list" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-resource" + ], + "summary": "Bind resource to memo", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to bind resource to", + "name": "memoId", + "in": "path", + "required": true + }, + { + "description": "Memo resource request object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertMemoResourceRequest" + } + } + ], + "responses": { + "200": { + "description": "Memo resource binded", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted post memo resource request | Resource not found" + }, + "401": { + "description": "Missing user in session | Unauthorized to bind this resource" + }, + "500": { + "description": "Failed to fetch resource | Failed to upsert memo resource" + } + } + } + }, + "/api/v1/memo/{memoId}/resource/{resourceId}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "memo-resource" + ], + "summary": "Unbind resource from memo", + "parameters": [ + { + "type": "integer", + "description": "ID of memo to unbind resource from", + "name": "memoId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of resource to unbind from memo", + "name": "resourceId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Memo resource unbinded. *200 is returned even if the reference doesn't exists ", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "Memo ID is not a number: %s | Resource ID is not a number: %s | Memo not found" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find memo | Failed to fetch resource list" + } + } + } + }, + "/api/v1/ping": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Ping the system", + "responses": { + "200": { + "description": "System profile", + "schema": { + "$ref": "#/definitions/profile.Profile" + } + } + } + } + }, + "/api/v1/resource": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "resource" + ], + "summary": "Get a list of resources", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Resource list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Resource" + } + } + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to fetch resource list" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "resource" + ], + "summary": "Create resource", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.CreateResourceRequest" + } + } + ], + "responses": { + "200": { + "description": "Created resource", + "schema": { + "$ref": "#/definitions/store.Resource" + } + }, + "400": { + "description": "Malformatted post resource request | Invalid external link | Invalid external link scheme | Failed to request %s | Failed to read %s | Failed to read mime from %s" + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to save resource | Failed to create resource | Failed to create activity" + } + } + } + }, + "/api/v1/resource/blob": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "resource" + ], + "summary": "Upload resource", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Created resource", + "schema": { + "$ref": "#/definitions/store.Resource" + } + }, + "400": { + "description": "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data" + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to get uploading file | Failed to open file | Failed to save resource | Failed to create resource | Failed to create activity" + } + } + } + }, + "/api/v1/resource/{resourceId}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "resource" + ], + "summary": "Delete a resource", + "parameters": [ + { + "type": "integer", + "description": "Resource ID", + "name": "resourceId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Resource deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s" + }, + "401": { + "description": "Missing user in session" + }, + "404": { + "description": "Resource not found: %d" + }, + "500": { + "description": "Failed to find resource | Failed to delete resource" + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "resource" + ], + "summary": "Update a resource", + "parameters": [ + { + "type": "integer", + "description": "Resource ID", + "name": "resourceId", + "in": "path", + "required": true + }, + { + "description": "Patch resource request", + "name": "patch", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpdateResourceRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated resource", + "schema": { + "$ref": "#/definitions/store.Resource" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted patch resource request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "404": { + "description": "Resource not found: %d" + }, + "500": { + "description": "Failed to find resource | Failed to patch resource" + } + } + } + }, + "/api/v1/status": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Get system status", + "responses": { + "200": { + "description": "System status", + "schema": { + "$ref": "#/definitions/v1.SystemStatus" + } + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find host user | Failed to find system setting list | Failed to unmarshal system setting customized profile value" + } + } + } + }, + "/api/v1/storage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Get a list of storages", + "responses": { + "200": { + "description": "List of storages", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Storage" + } + } + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to convert storage" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Create storage", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.CreateStorageRequest" + } + } + ], + "responses": { + "200": { + "description": "Created storage", + "schema": { + "$ref": "#/definitions/store.Storage" + } + }, + "400": { + "description": "Malformatted post storage request" + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to find user | Failed to create storage | Failed to convert storage" + } + } + } + }, + "/api/v1/storage/{storageId}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Delete a storage", + "parameters": [ + { + "type": "integer", + "description": "Storage ID", + "name": "storageId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Storage deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s | Storage service %d is using" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to find storage | Failed to unmarshal storage service id | Failed to delete storage" + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Update a storage", + "parameters": [ + { + "type": "integer", + "description": "Storage ID", + "name": "storageId", + "in": "path", + "required": true + }, + { + "description": "Patch request", + "name": "patch", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpdateStorageRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated resource", + "schema": { + "$ref": "#/definitions/store.Storage" + } + }, + "400": { + "description": "ID is not a number: %s | Malformatted patch storage request | Malformatted post storage request" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to patch storage | Failed to convert storage" + } + } + } + }, + "/api/v1/system/setting": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "system-setting" + ], + "summary": "Get a list of system settings", + "responses": { + "200": { + "description": "System setting list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.SystemSetting" + } + } + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to find system setting list" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "system-setting" + ], + "summary": "Create system setting", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertSystemSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "Created system setting", + "schema": { + "$ref": "#/definitions/store.SystemSetting" + } + }, + "400": { + "description": "Malformatted post system setting request | invalid system setting" + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "403": { + "description": "Cannot disable passwords if no SSO identity provider is configured." + }, + "500": { + "description": "Failed to find user | Failed to upsert system setting" + } + } + } + }, + "/api/v1/system/vacuum": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Vacuum the database", + "responses": { + "200": { + "description": "Database vacuumed", + "schema": { + "type": "boolean" + } + }, + "401": { + "description": "Missing user in session | Unauthorized" + }, + "500": { + "description": "Failed to find user | Failed to vacuum database" + } + } + } + }, + "/api/v1/tag": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Get a list of tags", + "responses": { + "200": { + "description": "Tag list", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Missing user id to find tag" + }, + "500": { + "description": "Failed to find tag list" + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Create a tag", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertTagRequest" + } + } + ], + "responses": { + "200": { + "description": "Created tag name", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Malformatted post tag request | Tag name shouldn't be empty" + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to upsert tag | Failed to create activity" + } + } + } + }, + "/api/v1/tag/delete": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Delete a tag", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.DeleteTagRequest" + } + } + ], + "responses": { + "200": { + "description": "Tag deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "Malformatted post tag request | Tag name shouldn't be empty" + }, + "401": { + "description": "Missing user in session" + }, + "500": { + "description": "Failed to delete tag name: %v" + } + } + } + }, + "/api/v1/tag/suggestion": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Get a list of tags suggested from other memos contents", + "responses": { + "200": { + "description": "Tag list", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Missing user session" + }, + "500": { + "description": "Failed to find memo list | Failed to find tag list" + } + } + } + }, + "/api/v1/user": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a list of users", + "responses": { + "200": { + "description": "User list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.User" + } + } + }, + "500": { + "description": "Failed to fetch user list" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a user", + "parameters": [ + { + "description": "Request object", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.CreateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "Created user", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Malformatted post user request | Invalid user create format" + }, + "401": { + "description": "Missing auth session | Unauthorized to create user" + }, + "403": { + "description": "Could not create host user" + }, + "500": { + "description": "Failed to find user by id | Failed to generate password hash | Failed to create user | Failed to create activity" + } + } + } + }, + "/api/v1/user/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "Current user", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "401": { + "description": "Missing auth session" + }, + "500": { + "description": "Failed to find user | Failed to find userSettingList" + } + } + } + }, + "/api/v1/user/name/{username}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user by username", + "parameters": [ + { + "type": "string", + "description": "Username", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Requested user", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to find user" + } + } + } + }, + "/api/v1/user/setting": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-setting" + ], + "summary": "Create user setting", + "parameters": [ + { + "description": "Request object.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpsertUserSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "Created user setting", + "schema": { + "$ref": "#/definitions/store.UserSetting" + } + }, + "400": { + "description": "Malformatted post user setting upsert request | Invalid user setting format" + }, + "401": { + "description": "Missing auth session" + }, + "500": { + "description": "Failed to upsert user setting" + } + } + } + }, + "/api/v1/user/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Requested user", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Malformatted user id" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to find user" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "User deleted", + "schema": { + "type": "boolean" + } + }, + "400": { + "description": "ID is not a number: %s | Current session user not found with ID: %d" + }, + "401": { + "description": "Missing user in session" + }, + "403": { + "description": "Unauthorized to delete user" + }, + "500": { + "description": "Failed to find user | Failed to delete user" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update a user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch request", + "name": "patch", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated user", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "ID is not a number: %s | Current session user not found with ID: %d | Malformatted patch user request | Invalid update user request" + }, + "401": { + "description": "Missing user in session" + }, + "403": { + "description": "Unauthorized to update user" + }, + "500": { + "description": "Failed to find user | Failed to generate password hash | Failed to patch user | Failed to find userSettingList" + } + } + } + }, + "/explore/rss.xml": { + "get": { + "produces": [ + "text/xml" + ], + "tags": [ + "rss" + ], + "summary": "Get RSS", + "responses": { + "200": { + "description": "RSS" + }, + "500": { + "description": "Failed to get system customized profile | Failed to find memo list | Failed to generate rss" + } + } + } + }, + "/o/get/httpmeta": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "get" + ], + "summary": "Get website metadata", + "parameters": [ + { + "type": "string", + "description": "Website URL", + "name": "url", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Extracted metadata", + "schema": { + "$ref": "#/definitions/getter.HTMLMeta" + } + }, + "400": { + "description": "Missing website url | Wrong url" + }, + "406": { + "description": "Failed to get website meta with url: %s" + } + } + } + }, + "/o/get/image": { + "get": { + "produces": [ + "image/*" + ], + "tags": [ + "get" + ], + "summary": "Get image from URL", + "parameters": [ + { + "type": "string", + "description": "Image url", + "name": "url", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Image" + }, + "400": { + "description": "Missing image url | Wrong url | Failed to get image url: %s" + }, + "500": { + "description": "Failed to write image blob" + } + } + } + }, + "/o/r/{resourceId}": { + "get": { + "description": "*Swagger UI may have problems displaying other file types than images", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "resource" + ], + "summary": "Stream a resource", + "parameters": [ + { + "type": "integer", + "description": "Resource ID", + "name": "resourceId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Thumbnail", + "name": "thumbnail", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Requested resource" + }, + "400": { + "description": "ID is not a number: %s | Failed to get resource visibility" + }, + "401": { + "description": "Resource visibility not match" + }, + "404": { + "description": "Resource not found: %d" + }, + "500": { + "description": "Failed to find resource by ID: %v | Failed to open the local resource: %s | Failed to read the local resource: %s" + } + } + } + }, + "/u/{id}/rss.xml": { + "get": { + "produces": [ + "text/xml" + ], + "tags": [ + "rss" + ], + "summary": "Get RSS for a user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "RSS" + }, + "400": { + "description": "User id is not a number" + }, + "500": { + "description": "Failed to get system customized profile | Failed to find memo list | Failed to generate rss" + } + } + } + } + }, + "definitions": { + "getter.HTMLMeta": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "profile.Profile": { + "type": "object", + "properties": { + "mode": { + "description": "Mode can be \"prod\" or \"dev\" or \"demo\"", + "type": "string" + }, + "version": { + "description": "Version is the current version of server", + "type": "string" + } + } + }, + "store.FieldMapping": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "identifier": { + "type": "string" + } + } + }, + "store.IdentityProvider": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/store.IdentityProviderConfig" + }, + "id": { + "type": "integer" + }, + "identifierFilter": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/store.IdentityProviderType" + } + } + }, + "store.IdentityProviderConfig": { + "type": "object", + "properties": { + "oauth2Config": { + "$ref": "#/definitions/store.IdentityProviderOAuth2Config" + } + } + }, + "store.IdentityProviderOAuth2Config": { + "type": "object", + "properties": { + "authUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "fieldMapping": { + "$ref": "#/definitions/store.FieldMapping" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "tokenUrl": { + "type": "string" + }, + "userInfoUrl": { + "type": "string" + } + } + }, + "store.IdentityProviderType": { + "type": "string", + "enum": [ + "OAUTH2" + ], + "x-enum-varnames": [ + "IdentityProviderOAuth2Type" + ] + }, + "store.Memo": { + "type": "object", + "properties": { + "content": { + "description": "Domain specific fields", + "type": "string" + }, + "createdTs": { + "type": "integer" + }, + "creatorID": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "pinned": { + "description": "Composed fields", + "type": "boolean" + }, + "relationList": { + "type": "array", + "items": { + "$ref": "#/definitions/store.MemoRelation" + } + }, + "resourceIDList": { + "type": "array", + "items": { + "type": "integer" + } + }, + "rowStatus": { + "description": "Standard fields", + "allOf": [ + { + "$ref": "#/definitions/store.RowStatus" + } + ] + }, + "updatedTs": { + "type": "integer" + }, + "visibility": { + "$ref": "#/definitions/store.Visibility" + } + } + }, + "store.MemoRelation": { + "type": "object", + "properties": { + "memoID": { + "type": "integer" + }, + "relatedMemoID": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/store.MemoRelationType" + } + } + }, + "store.MemoRelationType": { + "type": "string", + "enum": [ + "REFERENCE", + "ADDITIONAL" + ], + "x-enum-varnames": [ + "MemoRelationReference", + "MemoRelationAdditional" + ] + }, + "store.Resource": { + "type": "object", + "properties": { + "blob": { + "type": "array", + "items": { + "type": "integer" + } + }, + "createdTs": { + "type": "integer" + }, + "creatorID": { + "description": "Standard fields", + "type": "integer" + }, + "externalLink": { + "type": "string" + }, + "filename": { + "description": "Domain specific fields", + "type": "string" + }, + "id": { + "type": "integer" + }, + "internalPath": { + "type": "string" + }, + "linkedMemoAmount": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updatedTs": { + "type": "integer" + } + } + }, + "store.Role": { + "type": "string", + "enum": [ + "HOST", + "ADMIN", + "USER" + ], + "x-enum-varnames": [ + "RoleHost", + "RoleAdmin", + "RoleUser" + ] + }, + "store.RowStatus": { + "type": "string", + "enum": [ + "NORMAL", + "ARCHIVED" + ], + "x-enum-varnames": [ + "Normal", + "Archived" + ] + }, + "store.Storage": { + "type": "object", + "properties": { + "config": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "store.SystemSetting": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "store.User": { + "type": "object", + "properties": { + "avatarURL": { + "type": "string" + }, + "createdTs": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "openID": { + "type": "string" + }, + "passwordHash": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/store.Role" + }, + "rowStatus": { + "description": "Standard fields", + "allOf": [ + { + "$ref": "#/definitions/store.RowStatus" + } + ] + }, + "updatedTs": { + "type": "integer" + }, + "username": { + "description": "Domain specific fields", + "type": "string" + } + } + }, + "store.UserSetting": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "userID": { + "type": "integer" + }, + "value": { + "type": "string" + } + } + }, + "store.Visibility": { + "type": "string", + "enum": [ + "PUBLIC", + "PROTECTED", + "PRIVATE" + ], + "x-enum-varnames": [ + "Public", + "Protected", + "Private" + ] + }, + "v1.CreateIdentityProviderRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1.IdentityProviderConfig" + }, + "identifierFilter": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1.IdentityProviderType" + } + } + }, + "v1.CreateMemoRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdTs": { + "type": "integer" + }, + "relationList": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.UpsertMemoRelationRequest" + } + }, + "resourceIdList": { + "description": "Related fields", + "type": "array", + "items": { + "type": "integer" + } + }, + "visibility": { + "description": "Domain specific fields", + "allOf": [ + { + "$ref": "#/definitions/v1.Visibility" + } + ] + } + } + }, + "v1.CreateResourceRequest": { + "type": "object", + "properties": { + "downloadToLocal": { + "type": "boolean" + }, + "externalLink": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "internalPath": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "v1.CreateStorageRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1.StorageConfig" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1.StorageType" + } + } + }, + "v1.CreateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/v1.Role" + }, + "username": { + "type": "string" + } + } + }, + "v1.CustomizedProfile": { + "type": "object", + "properties": { + "appearance": { + "description": "Appearance is the server default appearance.", + "type": "string" + }, + "description": { + "description": "Description is the server description.", + "type": "string" + }, + "externalUrl": { + "description": "ExternalURL is the external url of server. e.g. https://usermemos.com", + "type": "string" + }, + "locale": { + "description": "Locale is the server default locale.", + "type": "string" + }, + "logoUrl": { + "description": "LogoURL is the url of logo image.", + "type": "string" + }, + "name": { + "description": "Name is the server name, default is ` + "`" + `memos` + "`" + `", + "type": "string" + } + } + }, + "v1.DeleteTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "v1.FieldMapping": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "identifier": { + "type": "string" + } + } + }, + "v1.IdentityProvider": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1.IdentityProviderConfig" + }, + "id": { + "type": "integer" + }, + "identifierFilter": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1.IdentityProviderType" + } + } + }, + "v1.IdentityProviderConfig": { + "type": "object", + "properties": { + "oauth2Config": { + "$ref": "#/definitions/v1.IdentityProviderOAuth2Config" + } + } + }, + "v1.IdentityProviderOAuth2Config": { + "type": "object", + "properties": { + "authUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "fieldMapping": { + "$ref": "#/definitions/v1.FieldMapping" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "tokenUrl": { + "type": "string" + }, + "userInfoUrl": { + "type": "string" + } + } + }, + "v1.IdentityProviderType": { + "type": "string", + "enum": [ + "OAUTH2" + ], + "x-enum-varnames": [ + "IdentityProviderOAuth2Type" + ] + }, + "v1.MemoRelationType": { + "type": "string", + "enum": [ + "REFERENCE", + "ADDITIONAL" + ], + "x-enum-varnames": [ + "MemoRelationReference", + "MemoRelationAdditional" + ] + }, + "v1.PatchMemoRequest": { + "type": "object", + "properties": { + "content": { + "description": "Domain specific fields", + "type": "string" + }, + "createdTs": { + "description": "Standard fields", + "type": "integer" + }, + "relationList": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.UpsertMemoRelationRequest" + } + }, + "resourceIdList": { + "description": "Related fields", + "type": "array", + "items": { + "type": "integer" + } + }, + "rowStatus": { + "$ref": "#/definitions/v1.RowStatus" + }, + "updatedTs": { + "type": "integer" + }, + "visibility": { + "$ref": "#/definitions/v1.Visibility" + } + } + }, + "v1.Resource": { + "type": "object", + "properties": { + "createdTs": { + "type": "integer" + }, + "creatorId": { + "description": "Standard fields", + "type": "integer" + }, + "externalLink": { + "type": "string" + }, + "filename": { + "description": "Domain specific fields", + "type": "string" + }, + "id": { + "type": "integer" + }, + "linkedMemoAmount": { + "description": "Related fields", + "type": "integer" + }, + "size": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updatedTs": { + "type": "integer" + } + } + }, + "v1.Role": { + "type": "string", + "enum": [ + "HOST", + "ADMIN", + "USER" + ], + "x-enum-varnames": [ + "RoleHost", + "RoleAdmin", + "RoleUser" + ] + }, + "v1.RowStatus": { + "type": "string", + "enum": [ + "NORMAL", + "ARCHIVED" + ], + "x-enum-varnames": [ + "Normal", + "Archived" + ] + }, + "v1.SSOSignIn": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "identityProviderId": { + "type": "integer" + }, + "redirectUri": { + "type": "string" + } + } + }, + "v1.SignIn": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "v1.SignUp": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "v1.StorageConfig": { + "type": "object", + "properties": { + "s3Config": { + "$ref": "#/definitions/v1.StorageS3Config" + } + } + }, + "v1.StorageS3Config": { + "type": "object", + "properties": { + "accessKey": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "endPoint": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "secretKey": { + "type": "string" + }, + "urlPrefix": { + "type": "string" + }, + "urlSuffix": { + "type": "string" + } + } + }, + "v1.StorageType": { + "type": "string", + "enum": [ + "S3" + ], + "x-enum-varnames": [ + "StorageS3" + ] + }, + "v1.SystemSetting": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/v1.SystemSettingName" + }, + "value": { + "description": "Value is a JSON string with basic value.", + "type": "string" + } + } + }, + "v1.SystemSettingName": { + "type": "string", + "enum": [ + "server-id", + "secret-session", + "allow-signup", + "disable-password-login", + "disable-public-memos", + "max-upload-size-mib", + "additional-style", + "additional-script", + "customized-profile", + "storage-service-id", + "local-storage-path", + "telegram-bot-token", + "memo-display-with-updated-ts", + "auto-backup-interval" + ], + "x-enum-varnames": [ + "SystemSettingServerIDName", + "SystemSettingSecretSessionName", + "SystemSettingAllowSignUpName", + "SystemSettingDisablePasswordLoginName", + "SystemSettingDisablePublicMemosName", + "SystemSettingMaxUploadSizeMiBName", + "SystemSettingAdditionalStyleName", + "SystemSettingAdditionalScriptName", + "SystemSettingCustomizedProfileName", + "SystemSettingStorageServiceIDName", + "SystemSettingLocalStoragePathName", + "SystemSettingTelegramBotTokenName", + "SystemSettingMemoDisplayWithUpdatedTsName", + "SystemSettingAutoBackupIntervalName" + ] + }, + "v1.SystemStatus": { + "type": "object", + "properties": { + "additionalScript": { + "description": "Additional script.", + "type": "string" + }, + "additionalStyle": { + "description": "Additional style.", + "type": "string" + }, + "allowSignUp": { + "description": "System settings\nAllow sign up.", + "type": "boolean" + }, + "autoBackupInterval": { + "description": "Auto Backup Interval.", + "type": "integer" + }, + "customizedProfile": { + "description": "Customized server profile, including server name and external url.", + "allOf": [ + { + "$ref": "#/definitions/v1.CustomizedProfile" + } + ] + }, + "dbSize": { + "type": "integer" + }, + "disablePasswordLogin": { + "description": "Disable password login.", + "type": "boolean" + }, + "disablePublicMemos": { + "description": "Disable public memos.", + "type": "boolean" + }, + "host": { + "$ref": "#/definitions/v1.User" + }, + "localStoragePath": { + "description": "Local storage path.", + "type": "string" + }, + "maxUploadSizeMiB": { + "description": "Max upload size.", + "type": "integer" + }, + "memoDisplayWithUpdatedTs": { + "description": "Memo display with updated timestamp.", + "type": "boolean" + }, + "profile": { + "$ref": "#/definitions/profile.Profile" + }, + "storageServiceId": { + "description": "Storage service ID.", + "type": "integer" + } + } + }, + "v1.UpdateIdentityProviderRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1.IdentityProviderConfig" + }, + "identifierFilter": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1.IdentityProviderType" + } + } + }, + "v1.UpdateResourceRequest": { + "type": "object", + "properties": { + "filename": { + "type": "string" + } + } + }, + "v1.UpdateStorageRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1.StorageConfig" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1.StorageType" + } + } + }, + "v1.UpdateUserRequest": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "resetOpenId": { + "type": "boolean" + }, + "rowStatus": { + "$ref": "#/definitions/v1.RowStatus" + }, + "username": { + "type": "string" + } + } + }, + "v1.UpsertMemoOrganizerRequest": { + "type": "object", + "properties": { + "pinned": { + "type": "boolean" + } + } + }, + "v1.UpsertMemoRelationRequest": { + "type": "object", + "properties": { + "relatedMemoId": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/v1.MemoRelationType" + } + } + }, + "v1.UpsertMemoResourceRequest": { + "type": "object", + "properties": { + "resourceId": { + "type": "integer" + }, + "updatedTs": { + "type": "integer" + } + } + }, + "v1.UpsertSystemSettingRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/v1.SystemSettingName" + }, + "value": { + "type": "string" + } + } + }, + "v1.UpsertTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "v1.UpsertUserSettingRequest": { + "type": "object", + "properties": { + "key": { + "$ref": "#/definitions/v1.UserSettingKey" + }, + "value": { + "type": "string" + } + } + }, + "v1.User": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "createdTs": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "openId": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/v1.Role" + }, + "rowStatus": { + "description": "Standard fields", + "allOf": [ + { + "$ref": "#/definitions/v1.RowStatus" + } + ] + }, + "updatedTs": { + "type": "integer" + }, + "userSettingList": { + "type": "array", + "items": { + "$ref": "#/definitions/v1.UserSetting" + } + }, + "username": { + "description": "Domain specific fields", + "type": "string" + } + } + }, + "v1.UserSetting": { + "type": "object", + "properties": { + "key": { + "$ref": "#/definitions/v1.UserSettingKey" + }, + "userId": { + "type": "integer" + }, + "value": { + "type": "string" + } + } + }, + "v1.UserSettingKey": { + "type": "string", + "enum": [ + "locale", + "appearance", + "memo-visibility", + "telegram-user-id" + ], + "x-enum-varnames": [ + "UserSettingLocaleKey", + "UserSettingAppearanceKey", + "UserSettingMemoVisibilityKey", + "UserSettingTelegramUserIDKey" + ] + }, + "v1.Visibility": { + "type": "string", + "enum": [ + "PUBLIC", + "PROTECTED", + "PRIVATE" + ], + "x-enum-varnames": [ + "Public", + "Protected", + "Private" + ] + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "description": "Insert your Open ID API Key here.", + "type": "apiKey", + "name": "openId", + "in": "query" + } + }, + "externalDocs": { + "description": "Find out more about Memos", + "url": "https://usememos.com/" + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "memos API", + Description: "A privacy-first, lightweight note-taking service.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/api/swagger.yaml b/api/swagger.yaml new file mode 100644 index 00000000..8a438b1f --- /dev/null +++ b/api/swagger.yaml @@ -0,0 +1,2248 @@ +basePath: / +definitions: + getter.HTMLMeta: + properties: + description: + type: string + image: + type: string + title: + type: string + type: object + profile.Profile: + properties: + mode: + description: Mode can be "prod" or "dev" or "demo" + type: string + version: + description: Version is the current version of server + type: string + type: object + store.FieldMapping: + properties: + displayName: + type: string + email: + type: string + identifier: + type: string + type: object + store.IdentityProvider: + properties: + config: + $ref: '#/definitions/store.IdentityProviderConfig' + id: + type: integer + identifierFilter: + type: string + name: + type: string + type: + $ref: '#/definitions/store.IdentityProviderType' + type: object + store.IdentityProviderConfig: + properties: + oauth2Config: + $ref: '#/definitions/store.IdentityProviderOAuth2Config' + type: object + store.IdentityProviderOAuth2Config: + properties: + authUrl: + type: string + clientId: + type: string + clientSecret: + type: string + fieldMapping: + $ref: '#/definitions/store.FieldMapping' + scopes: + items: + type: string + type: array + tokenUrl: + type: string + userInfoUrl: + type: string + type: object + store.IdentityProviderType: + enum: + - OAUTH2 + type: string + x-enum-varnames: + - IdentityProviderOAuth2Type + store.Memo: + properties: + content: + description: Domain specific fields + type: string + createdTs: + type: integer + creatorID: + type: integer + id: + type: integer + pinned: + description: Composed fields + type: boolean + relationList: + items: + $ref: '#/definitions/store.MemoRelation' + type: array + resourceIDList: + items: + type: integer + type: array + rowStatus: + allOf: + - $ref: '#/definitions/store.RowStatus' + description: Standard fields + updatedTs: + type: integer + visibility: + $ref: '#/definitions/store.Visibility' + type: object + store.MemoRelation: + properties: + memoID: + type: integer + relatedMemoID: + type: integer + type: + $ref: '#/definitions/store.MemoRelationType' + type: object + store.MemoRelationType: + enum: + - REFERENCE + - ADDITIONAL + type: string + x-enum-varnames: + - MemoRelationReference + - MemoRelationAdditional + store.Resource: + properties: + blob: + items: + type: integer + type: array + createdTs: + type: integer + creatorID: + description: Standard fields + type: integer + externalLink: + type: string + filename: + description: Domain specific fields + type: string + id: + type: integer + internalPath: + type: string + linkedMemoAmount: + type: integer + size: + type: integer + type: + type: string + updatedTs: + type: integer + type: object + store.Role: + enum: + - HOST + - ADMIN + - USER + type: string + x-enum-varnames: + - RoleHost + - RoleAdmin + - RoleUser + store.RowStatus: + enum: + - NORMAL + - ARCHIVED + type: string + x-enum-varnames: + - Normal + - Archived + store.Storage: + properties: + config: + type: string + id: + type: integer + name: + type: string + type: + type: string + type: object + store.SystemSetting: + properties: + description: + type: string + name: + type: string + value: + type: string + type: object + store.User: + properties: + avatarURL: + type: string + createdTs: + type: integer + email: + type: string + id: + type: integer + nickname: + type: string + openID: + type: string + passwordHash: + type: string + role: + $ref: '#/definitions/store.Role' + rowStatus: + allOf: + - $ref: '#/definitions/store.RowStatus' + description: Standard fields + updatedTs: + type: integer + username: + description: Domain specific fields + type: string + type: object + store.UserSetting: + properties: + key: + type: string + userID: + type: integer + value: + type: string + type: object + store.Visibility: + enum: + - PUBLIC + - PROTECTED + - PRIVATE + type: string + x-enum-varnames: + - Public + - Protected + - Private + v1.CreateIdentityProviderRequest: + properties: + config: + $ref: '#/definitions/v1.IdentityProviderConfig' + identifierFilter: + type: string + name: + type: string + type: + $ref: '#/definitions/v1.IdentityProviderType' + type: object + v1.CreateMemoRequest: + properties: + content: + type: string + createdTs: + type: integer + relationList: + items: + $ref: '#/definitions/v1.UpsertMemoRelationRequest' + type: array + resourceIdList: + description: Related fields + items: + type: integer + type: array + visibility: + allOf: + - $ref: '#/definitions/v1.Visibility' + description: Domain specific fields + type: object + v1.CreateResourceRequest: + properties: + downloadToLocal: + type: boolean + externalLink: + type: string + filename: + type: string + internalPath: + type: string + type: + type: string + type: object + v1.CreateStorageRequest: + properties: + config: + $ref: '#/definitions/v1.StorageConfig' + name: + type: string + type: + $ref: '#/definitions/v1.StorageType' + type: object + v1.CreateUserRequest: + properties: + email: + type: string + nickname: + type: string + password: + type: string + role: + $ref: '#/definitions/v1.Role' + username: + type: string + type: object + v1.CustomizedProfile: + properties: + appearance: + description: Appearance is the server default appearance. + type: string + description: + description: Description is the server description. + type: string + externalUrl: + description: ExternalURL is the external url of server. e.g. https://usermemos.com + type: string + locale: + description: Locale is the server default locale. + type: string + logoUrl: + description: LogoURL is the url of logo image. + type: string + name: + description: Name is the server name, default is `memos` + type: string + type: object + v1.DeleteTagRequest: + properties: + name: + type: string + type: object + v1.FieldMapping: + properties: + displayName: + type: string + email: + type: string + identifier: + type: string + type: object + v1.IdentityProvider: + properties: + config: + $ref: '#/definitions/v1.IdentityProviderConfig' + id: + type: integer + identifierFilter: + type: string + name: + type: string + type: + $ref: '#/definitions/v1.IdentityProviderType' + type: object + v1.IdentityProviderConfig: + properties: + oauth2Config: + $ref: '#/definitions/v1.IdentityProviderOAuth2Config' + type: object + v1.IdentityProviderOAuth2Config: + properties: + authUrl: + type: string + clientId: + type: string + clientSecret: + type: string + fieldMapping: + $ref: '#/definitions/v1.FieldMapping' + scopes: + items: + type: string + type: array + tokenUrl: + type: string + userInfoUrl: + type: string + type: object + v1.IdentityProviderType: + enum: + - OAUTH2 + type: string + x-enum-varnames: + - IdentityProviderOAuth2Type + v1.MemoRelationType: + enum: + - REFERENCE + - ADDITIONAL + type: string + x-enum-varnames: + - MemoRelationReference + - MemoRelationAdditional + v1.PatchMemoRequest: + properties: + content: + description: Domain specific fields + type: string + createdTs: + description: Standard fields + type: integer + relationList: + items: + $ref: '#/definitions/v1.UpsertMemoRelationRequest' + type: array + resourceIdList: + description: Related fields + items: + type: integer + type: array + rowStatus: + $ref: '#/definitions/v1.RowStatus' + updatedTs: + type: integer + visibility: + $ref: '#/definitions/v1.Visibility' + type: object + v1.Resource: + properties: + createdTs: + type: integer + creatorId: + description: Standard fields + type: integer + externalLink: + type: string + filename: + description: Domain specific fields + type: string + id: + type: integer + linkedMemoAmount: + description: Related fields + type: integer + size: + type: integer + type: + type: string + updatedTs: + type: integer + type: object + v1.Role: + enum: + - HOST + - ADMIN + - USER + type: string + x-enum-varnames: + - RoleHost + - RoleAdmin + - RoleUser + v1.RowStatus: + enum: + - NORMAL + - ARCHIVED + type: string + x-enum-varnames: + - Normal + - Archived + v1.SSOSignIn: + properties: + code: + type: string + identityProviderId: + type: integer + redirectUri: + type: string + type: object + v1.SignIn: + properties: + password: + type: string + username: + type: string + type: object + v1.SignUp: + properties: + password: + type: string + username: + type: string + type: object + v1.StorageConfig: + properties: + s3Config: + $ref: '#/definitions/v1.StorageS3Config' + type: object + v1.StorageS3Config: + properties: + accessKey: + type: string + bucket: + type: string + endPoint: + type: string + path: + type: string + region: + type: string + secretKey: + type: string + urlPrefix: + type: string + urlSuffix: + type: string + type: object + v1.StorageType: + enum: + - S3 + type: string + x-enum-varnames: + - StorageS3 + v1.SystemSetting: + properties: + description: + type: string + name: + $ref: '#/definitions/v1.SystemSettingName' + value: + description: Value is a JSON string with basic value. + type: string + type: object + v1.SystemSettingName: + enum: + - server-id + - secret-session + - allow-signup + - disable-password-login + - disable-public-memos + - max-upload-size-mib + - additional-style + - additional-script + - customized-profile + - storage-service-id + - local-storage-path + - telegram-bot-token + - memo-display-with-updated-ts + - auto-backup-interval + type: string + x-enum-varnames: + - SystemSettingServerIDName + - SystemSettingSecretSessionName + - SystemSettingAllowSignUpName + - SystemSettingDisablePasswordLoginName + - SystemSettingDisablePublicMemosName + - SystemSettingMaxUploadSizeMiBName + - SystemSettingAdditionalStyleName + - SystemSettingAdditionalScriptName + - SystemSettingCustomizedProfileName + - SystemSettingStorageServiceIDName + - SystemSettingLocalStoragePathName + - SystemSettingTelegramBotTokenName + - SystemSettingMemoDisplayWithUpdatedTsName + - SystemSettingAutoBackupIntervalName + v1.SystemStatus: + properties: + additionalScript: + description: Additional script. + type: string + additionalStyle: + description: Additional style. + type: string + allowSignUp: + description: |- + System settings + Allow sign up. + type: boolean + autoBackupInterval: + description: Auto Backup Interval. + type: integer + customizedProfile: + allOf: + - $ref: '#/definitions/v1.CustomizedProfile' + description: Customized server profile, including server name and external + url. + dbSize: + type: integer + disablePasswordLogin: + description: Disable password login. + type: boolean + disablePublicMemos: + description: Disable public memos. + type: boolean + host: + $ref: '#/definitions/v1.User' + localStoragePath: + description: Local storage path. + type: string + maxUploadSizeMiB: + description: Max upload size. + type: integer + memoDisplayWithUpdatedTs: + description: Memo display with updated timestamp. + type: boolean + profile: + $ref: '#/definitions/profile.Profile' + storageServiceId: + description: Storage service ID. + type: integer + type: object + v1.UpdateIdentityProviderRequest: + properties: + config: + $ref: '#/definitions/v1.IdentityProviderConfig' + identifierFilter: + type: string + name: + type: string + type: + $ref: '#/definitions/v1.IdentityProviderType' + type: object + v1.UpdateResourceRequest: + properties: + filename: + type: string + type: object + v1.UpdateStorageRequest: + properties: + config: + $ref: '#/definitions/v1.StorageConfig' + name: + type: string + type: + $ref: '#/definitions/v1.StorageType' + type: object + v1.UpdateUserRequest: + properties: + avatarUrl: + type: string + email: + type: string + nickname: + type: string + password: + type: string + resetOpenId: + type: boolean + rowStatus: + $ref: '#/definitions/v1.RowStatus' + username: + type: string + type: object + v1.UpsertMemoOrganizerRequest: + properties: + pinned: + type: boolean + type: object + v1.UpsertMemoRelationRequest: + properties: + relatedMemoId: + type: integer + type: + $ref: '#/definitions/v1.MemoRelationType' + type: object + v1.UpsertMemoResourceRequest: + properties: + resourceId: + type: integer + updatedTs: + type: integer + type: object + v1.UpsertSystemSettingRequest: + properties: + description: + type: string + name: + $ref: '#/definitions/v1.SystemSettingName' + value: + type: string + type: object + v1.UpsertTagRequest: + properties: + name: + type: string + type: object + v1.UpsertUserSettingRequest: + properties: + key: + $ref: '#/definitions/v1.UserSettingKey' + value: + type: string + type: object + v1.User: + properties: + avatarUrl: + type: string + createdTs: + type: integer + email: + type: string + id: + type: integer + nickname: + type: string + openId: + type: string + role: + $ref: '#/definitions/v1.Role' + rowStatus: + allOf: + - $ref: '#/definitions/v1.RowStatus' + description: Standard fields + updatedTs: + type: integer + userSettingList: + items: + $ref: '#/definitions/v1.UserSetting' + type: array + username: + description: Domain specific fields + type: string + type: object + v1.UserSetting: + properties: + key: + $ref: '#/definitions/v1.UserSettingKey' + userId: + type: integer + value: + type: string + type: object + v1.UserSettingKey: + enum: + - locale + - appearance + - memo-visibility + - telegram-user-id + type: string + x-enum-varnames: + - UserSettingLocaleKey + - UserSettingAppearanceKey + - UserSettingMemoVisibilityKey + - UserSettingTelegramUserIDKey + v1.Visibility: + enum: + - PUBLIC + - PROTECTED + - PRIVATE + type: string + x-enum-varnames: + - Public + - Protected + - Private +externalDocs: + description: Find out more about Memos + url: https://usememos.com/ +info: + contact: + name: API Support + url: https://github.com/orgs/usememos/discussions + description: A privacy-first, lightweight note-taking service. + license: + name: MIT License + url: https://github.com/usememos/memos/blob/main/LICENSE + title: memos API + version: "1.0" +paths: + /api/v1/auth/signin: + post: + consumes: + - application/json + parameters: + - description: Sign-in object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.SignIn' + produces: + - application/json + responses: + "200": + description: User information + schema: + $ref: '#/definitions/store.User' + "400": + description: Malformatted signin request + "401": + description: Password login is deactivated | Incorrect login credentials, + please try again + "403": + description: User has been archived with username %s + "500": + description: Failed to find system setting | Failed to unmarshal system + setting | Incorrect login credentials, please try again | Failed to generate + tokens | Failed to create activity + summary: Sign-in to memos. + tags: + - auth + /api/v1/auth/signin/sso: + post: + consumes: + - application/json + parameters: + - description: SSO sign-in object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.SSOSignIn' + produces: + - application/json + responses: + "200": + description: User information + schema: + $ref: '#/definitions/store.User' + "400": + description: Malformatted signin request + "401": + description: Access denied, identifier does not match the filter. + "403": + description: User has been archived with username {username} + "404": + description: Identity provider not found + "500": + description: Failed to find identity provider | Failed to create identity + provider instance | Failed to exchange token | Failed to get user info + | Failed to compile identifier filter | Incorrect login credentials, please + try again | Failed to generate random password | Failed to generate password + hash | Failed to create user | Failed to generate tokens | Failed to create + activity + summary: Sign-in to memos using SSO. + tags: + - auth + /api/v1/auth/signout: + post: + produces: + - application/json + responses: + "200": + description: Sign-out success + schema: + type: boolean + summary: Sign-out from memos. + tags: + - auth + /api/v1/auth/signup: + post: + consumes: + - application/json + parameters: + - description: Sign-up object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.SignUp' + produces: + - application/json + responses: + "200": + description: User information + schema: + $ref: '#/definitions/store.User' + "400": + description: Malformatted signup request | Failed to find users + "401": + description: signup is disabled + "403": + description: Forbidden + "404": + description: Not found + "500": + description: Failed to find system setting | Failed to unmarshal system + setting allow signup | Failed to generate password hash | Failed to create + user | Failed to generate tokens | Failed to create activity + summary: Sign-up to memos. + tags: + - auth + /api/v1/idp: + get: + description: '*clientSecret is only available for host user' + produces: + - application/json + responses: + "200": + description: List of available identity providers + schema: + items: + $ref: '#/definitions/v1.IdentityProvider' + type: array + "500": + description: Failed to find identity provider list | Failed to find user + summary: Get a list of identity providers + tags: + - idp + post: + consumes: + - application/json + parameters: + - description: Identity provider information + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateIdentityProviderRequest' + produces: + - application/json + responses: + "200": + description: Identity provider information + schema: + $ref: '#/definitions/store.IdentityProvider' + "400": + description: Malformatted post identity provider request + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to create identity provider + security: + - ApiKeyAuth: [] + summary: Create Identity Provider + tags: + - idp + /api/v1/idp/{idpId}: + delete: + consumes: + - application/json + parameters: + - description: Identity Provider ID + in: path + name: idpId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Identity Provider deleted + schema: + type: boolean + "400": + description: 'ID is not a number: %s | Malformatted patch identity provider + request' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to patch identity provider + security: + - ApiKeyAuth: [] + summary: Delete an identity provider by ID + tags: + - idp + get: + consumes: + - application/json + parameters: + - description: Identity provider ID + in: path + name: idpId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Requested identity provider + schema: + $ref: '#/definitions/store.IdentityProvider' + "400": + description: 'ID is not a number: %s' + "401": + description: Missing user in session | Unauthorized + "404": + description: Identity provider not found + "500": + description: Failed to find identity provider list | Failed to find user + security: + - ApiKeyAuth: [] + summary: Get an identity provider by ID + tags: + - idp + patch: + consumes: + - application/json + parameters: + - description: Identity Provider ID + in: path + name: idpId + required: true + type: integer + - description: Patched identity provider information + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpdateIdentityProviderRequest' + produces: + - application/json + responses: + "200": + description: Patched identity provider + schema: + $ref: '#/definitions/store.IdentityProvider' + "400": + description: 'ID is not a number: %s | Malformatted patch identity provider + request' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to patch identity provider + security: + - ApiKeyAuth: [] + summary: Update an identity provider by ID + tags: + - idp + /api/v1/memo: + get: + parameters: + - description: Creator ID + in: query + name: creatorId + type: integer + - description: Creator username + in: query + name: creatorUsername + type: string + - description: Row status + enum: + - NORMAL + - ARCHIVED + in: query + name: rowStatus + type: string + - description: Pinned + in: query + name: pinned + type: boolean + - description: 'Search for tag. Do not append #' + in: query + name: tag + type: string + - description: Search for content + in: query + name: content + type: string + - description: Limit + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: Memo list + schema: + items: + $ref: '#/definitions/store.Memo' + type: array + "400": + description: Missing user to find memo + "500": + description: Failed to get memo display with updated ts setting value | + Failed to fetch memo list | Failed to compose memo response + security: + - ApiKeyAuth: [] + summary: Get a list of memos matching optional filters + tags: + - memo + post: + consumes: + - application/json + description: |- + Visibility can be PUBLIC, PROTECTED or PRIVATE + *You should omit fields to use their default values + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateMemoRequest' + produces: + - application/json + responses: + "200": + description: Stored memo + schema: + $ref: '#/definitions/store.Memo' + "400": + description: Malformatted post memo request | Content size overflow, up + to 1MB + "401": + description: Missing user in session + "404": + description: 'User not found | Memo not found: %d' + "500": + description: Failed to find user setting | Failed to unmarshal user setting + value | Failed to find system setting | Failed to unmarshal system setting + | Failed to find user | Failed to create memo | Failed to create activity + | Failed to upsert memo resource | Failed to upsert memo relation | Failed + to compose memo | Failed to compose memo response + security: + - ApiKeyAuth: [] + summary: Create a memo + tags: + - memo + /api/v1/memo/{memoId}: + delete: + parameters: + - description: Memo ID to delete + in: path + name: memoId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Memo deleted + schema: + type: boolean + "400": + description: 'ID is not a number: %s' + "401": + description: Missing user in session | Unauthorized + "404": + description: 'Memo not found: %d' + "500": + description: 'Failed to find memo | Failed to delete memo ID: %v' + security: + - ApiKeyAuth: [] + summary: Delete memo by ID + tags: + - memo + get: + parameters: + - description: Memo ID + in: path + name: memoId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Memo list + schema: + items: + $ref: '#/definitions/store.Memo' + type: array + "400": + description: 'ID is not a number: %s' + "401": + description: Missing user in session + "403": + description: this memo is private only | this memo is protected, missing + user in session + "404": + description: 'Memo not found: %d' + "500": + description: 'Failed to find memo by ID: %v | Failed to compose memo response' + summary: Get memo by ID + tags: + - memo + patch: + consumes: + - application/json + description: |- + Visibility can be PUBLIC, PROTECTED or PRIVATE + *You should omit fields to use their default values + parameters: + - description: ID of memo to update + in: path + name: memoId + required: true + type: integer + - description: Patched object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.PatchMemoRequest' + produces: + - application/json + responses: + "200": + description: Stored memo + schema: + $ref: '#/definitions/store.Memo' + "400": + description: 'ID is not a number: %s | Malformatted patch memo request | + Content size overflow, up to 1MB' + "401": + description: Missing user in session | Unauthorized + "404": + description: 'Memo not found: %d' + "500": + description: Failed to find memo | Failed to patch memo | Failed to upsert + memo resource | Failed to delete memo resource | Failed to compose memo + response + security: + - ApiKeyAuth: [] + summary: Update a memo + tags: + - memo + /api/v1/memo/{memoId}/organizer: + post: + consumes: + - application/json + parameters: + - description: ID of memo to organize + in: path + name: memoId + required: true + type: integer + - description: Memo organizer object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertMemoOrganizerRequest' + produces: + - application/json + responses: + "200": + description: Memo information + schema: + $ref: '#/definitions/store.Memo' + "400": + description: 'ID is not a number: %s | Malformatted post memo organizer + request' + "401": + description: Missing user in session | Unauthorized + "404": + description: 'Memo not found: %v' + "500": + description: 'Failed to find memo | Failed to upsert memo organizer | Failed + to find memo by ID: %v | Failed to compose memo response' + security: + - ApiKeyAuth: [] + summary: Organize memo (pin/unpin) + tags: + - memo-organizer + /api/v1/memo/{memoId}/relation: + get: + consumes: + - application/json + parameters: + - description: ID of memo to find relations + in: path + name: memoId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Memo relation information list + schema: + items: + $ref: '#/definitions/store.MemoRelation' + type: array + "400": + description: 'ID is not a number: %s' + "500": + description: Failed to list memo relations + summary: Get a list of Memo Relations + tags: + - memo-relation + post: + consumes: + - application/json + description: Create a relation between two memos + parameters: + - description: ID of memo to relate + in: path + name: memoId + required: true + type: integer + - description: Memo relation object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertMemoRelationRequest' + produces: + - application/json + responses: + "200": + description: Memo relation information + schema: + $ref: '#/definitions/store.MemoRelation' + "400": + description: 'ID is not a number: %s | Malformatted post memo relation request' + "500": + description: Failed to upsert memo relation + summary: Create Memo Relation + tags: + - memo-relation + /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType}: + delete: + consumes: + - application/json + description: Removes a relation between two memos + parameters: + - description: ID of memo to find relations + in: path + name: memoId + required: true + type: integer + - description: ID of memo to remove relation to + in: path + name: relatedMemoId + required: true + type: integer + - description: Type of relation to remove + enum: + - REFERENCE + - ADDITIONAL + in: path + name: relationType + required: true + type: string + produces: + - application/json + responses: + "200": + description: Memo relation deleted + schema: + type: boolean + "400": + description: 'Memo ID is not a number: %s | Related memo ID is not a number: + %s' + "500": + description: Failed to delete memo relation + summary: Delete a Memo Relation + tags: + - memo-relation + /api/v1/memo/{memoId}/resource: + get: + consumes: + - application/json + parameters: + - description: ID of memo to fetch resource list from + in: path + name: memoId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Memo resource list + schema: + items: + $ref: '#/definitions/v1.Resource' + type: array + "400": + description: 'ID is not a number: %s' + "500": + description: Failed to fetch resource list + summary: Get resource list of a memo + tags: + - memo-resource + post: + consumes: + - application/json + parameters: + - description: ID of memo to bind resource to + in: path + name: memoId + required: true + type: integer + - description: Memo resource request object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertMemoResourceRequest' + produces: + - application/json + responses: + "200": + description: Memo resource binded + schema: + type: boolean + "400": + description: 'ID is not a number: %s | Malformatted post memo resource request + | Resource not found' + "401": + description: Missing user in session | Unauthorized to bind this resource + "500": + description: Failed to fetch resource | Failed to upsert memo resource + security: + - ApiKeyAuth: [] + summary: Bind resource to memo + tags: + - memo-resource + /api/v1/memo/{memoId}/resource/{resourceId}: + delete: + consumes: + - application/json + parameters: + - description: ID of memo to unbind resource from + in: path + name: memoId + required: true + type: integer + - description: ID of resource to unbind from memo + in: path + name: resourceId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 'Memo resource unbinded. *200 is returned even if the reference + doesn''t exists ' + schema: + type: boolean + "400": + description: 'Memo ID is not a number: %s | Resource ID is not a number: + %s | Memo not found' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find memo | Failed to fetch resource list + security: + - ApiKeyAuth: [] + summary: Unbind resource from memo + tags: + - memo-resource + /api/v1/memo/all: + get: + description: |- + This should also list protected memos if the user is logged in + Authentication is optional + parameters: + - description: Limit + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: Memo list + schema: + items: + $ref: '#/definitions/store.Memo' + type: array + "500": + description: Failed to get memo display with updated ts setting value | + Failed to fetch all memo list | Failed to compose memo response + security: + - ApiKeyAuth: [] + summary: Get a list of public memos matching optional filters + tags: + - memo + /api/v1/memo/stats: + get: + description: Used to generate the heatmap + parameters: + - description: Creator ID + in: query + name: creatorId + type: integer + - description: Creator username + in: query + name: creatorUsername + type: string + produces: + - application/json + responses: + "200": + description: Memo createdTs list + schema: + items: + type: integer + type: array + "400": + description: Missing user id to find memo + "500": + description: Failed to get memo display with updated ts setting value | + Failed to find memo list | Failed to compose memo response + summary: Get memo stats by creator ID or username + tags: + - memo + /api/v1/ping: + get: + produces: + - application/json + responses: + "200": + description: System profile + schema: + $ref: '#/definitions/profile.Profile' + summary: Ping the system + tags: + - system + /api/v1/resource: + get: + parameters: + - description: Limit + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: Resource list + schema: + items: + $ref: '#/definitions/store.Resource' + type: array + "401": + description: Missing user in session + "500": + description: Failed to fetch resource list + security: + - ApiKeyAuth: [] + summary: Get a list of resources + tags: + - resource + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateResourceRequest' + produces: + - application/json + responses: + "200": + description: Created resource + schema: + $ref: '#/definitions/store.Resource' + "400": + description: Malformatted post resource request | Invalid external link + | Invalid external link scheme | Failed to request %s | Failed to read + %s | Failed to read mime from %s + "401": + description: Missing user in session + "500": + description: Failed to save resource | Failed to create resource | Failed + to create activity + security: + - ApiKeyAuth: [] + summary: Create resource + tags: + - resource + /api/v1/resource/{resourceId}: + delete: + parameters: + - description: Resource ID + in: path + name: resourceId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Resource deleted + schema: + type: boolean + "400": + description: 'ID is not a number: %s' + "401": + description: Missing user in session + "404": + description: 'Resource not found: %d' + "500": + description: Failed to find resource | Failed to delete resource + security: + - ApiKeyAuth: [] + summary: Delete a resource + tags: + - resource + patch: + parameters: + - description: Resource ID + in: path + name: resourceId + required: true + type: integer + - description: Patch resource request + in: body + name: patch + required: true + schema: + $ref: '#/definitions/v1.UpdateResourceRequest' + produces: + - application/json + responses: + "200": + description: Updated resource + schema: + $ref: '#/definitions/store.Resource' + "400": + description: 'ID is not a number: %s | Malformatted patch resource request' + "401": + description: Missing user in session | Unauthorized + "404": + description: 'Resource not found: %d' + "500": + description: Failed to find resource | Failed to patch resource + security: + - ApiKeyAuth: [] + summary: Update a resource + tags: + - resource + /api/v1/resource/blob: + post: + consumes: + - multipart/form-data + parameters: + - description: File to upload + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: Created resource + schema: + $ref: '#/definitions/store.Resource' + "400": + description: Upload file not found | File size exceeds allowed limit of + %d MiB | Failed to parse upload data + "401": + description: Missing user in session + "500": + description: Failed to get uploading file | Failed to open file | Failed + to save resource | Failed to create resource | Failed to create activity + security: + - ApiKeyAuth: [] + summary: Upload resource + tags: + - resource + /api/v1/status: + get: + produces: + - application/json + responses: + "200": + description: System status + schema: + $ref: '#/definitions/v1.SystemStatus' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find host user | Failed to find system setting list + | Failed to unmarshal system setting customized profile value + summary: Get system status + tags: + - system + /api/v1/storage: + get: + produces: + - application/json + responses: + "200": + description: List of storages + schema: + items: + $ref: '#/definitions/store.Storage' + type: array + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to convert storage + security: + - ApiKeyAuth: [] + summary: Get a list of storages + tags: + - storage + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateStorageRequest' + produces: + - application/json + responses: + "200": + description: Created storage + schema: + $ref: '#/definitions/store.Storage' + "400": + description: Malformatted post storage request + "401": + description: Missing user in session + "500": + description: Failed to find user | Failed to create storage | Failed to + convert storage + security: + - ApiKeyAuth: [] + summary: Create storage + tags: + - storage + /api/v1/storage/{storageId}: + delete: + parameters: + - description: Storage ID + in: path + name: storageId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Storage deleted + schema: + type: boolean + "400": + description: 'ID is not a number: %s | Storage service %d is using' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to find storage | Failed to unmarshal + storage service id | Failed to delete storage + security: + - ApiKeyAuth: [] + summary: Delete a storage + tags: + - storage + patch: + parameters: + - description: Storage ID + in: path + name: storageId + required: true + type: integer + - description: Patch request + in: body + name: patch + required: true + schema: + $ref: '#/definitions/v1.UpdateStorageRequest' + produces: + - application/json + responses: + "200": + description: Updated resource + schema: + $ref: '#/definitions/store.Storage' + "400": + description: 'ID is not a number: %s | Malformatted patch storage request + | Malformatted post storage request' + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to patch storage | Failed to convert + storage + security: + - ApiKeyAuth: [] + summary: Update a storage + tags: + - storage + /api/v1/system/setting: + get: + produces: + - application/json + responses: + "200": + description: System setting list + schema: + items: + $ref: '#/definitions/v1.SystemSetting' + type: array + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to find system setting list + security: + - ApiKeyAuth: [] + summary: Get a list of system settings + tags: + - system-setting + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertSystemSettingRequest' + produces: + - application/json + responses: + "200": + description: Created system setting + schema: + $ref: '#/definitions/store.SystemSetting' + "400": + description: Malformatted post system setting request | invalid system setting + "401": + description: Missing user in session | Unauthorized + "403": + description: Cannot disable passwords if no SSO identity provider is configured. + "500": + description: Failed to find user | Failed to upsert system setting + security: + - ApiKeyAuth: [] + summary: Create system setting + tags: + - system-setting + /api/v1/system/vacuum: + post: + produces: + - application/json + responses: + "200": + description: Database vacuumed + schema: + type: boolean + "401": + description: Missing user in session | Unauthorized + "500": + description: Failed to find user | Failed to vacuum database + security: + - ApiKeyAuth: [] + summary: Vacuum the database + tags: + - system + /api/v1/tag: + get: + produces: + - application/json + responses: + "200": + description: Tag list + schema: + items: + type: string + type: array + "400": + description: Missing user id to find tag + "500": + description: Failed to find tag list + security: + - ApiKeyAuth: [] + summary: Get a list of tags + tags: + - tag + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertTagRequest' + produces: + - application/json + responses: + "200": + description: Created tag name + schema: + type: string + "400": + description: Malformatted post tag request | Tag name shouldn't be empty + "401": + description: Missing user in session + "500": + description: Failed to upsert tag | Failed to create activity + security: + - ApiKeyAuth: [] + summary: Create a tag + tags: + - tag + /api/v1/tag/delete: + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.DeleteTagRequest' + produces: + - application/json + responses: + "200": + description: Tag deleted + schema: + type: boolean + "400": + description: Malformatted post tag request | Tag name shouldn't be empty + "401": + description: Missing user in session + "500": + description: 'Failed to delete tag name: %v' + security: + - ApiKeyAuth: [] + summary: Delete a tag + tags: + - tag + /api/v1/tag/suggestion: + get: + produces: + - application/json + responses: + "200": + description: Tag list + schema: + items: + type: string + type: array + "400": + description: Missing user session + "500": + description: Failed to find memo list | Failed to find tag list + security: + - ApiKeyAuth: [] + summary: Get a list of tags suggested from other memos contents + tags: + - tag + /api/v1/user: + get: + produces: + - application/json + responses: + "200": + description: User list + schema: + items: + $ref: '#/definitions/store.User' + type: array + "500": + description: Failed to fetch user list + summary: Get a list of users + tags: + - user + post: + consumes: + - application/json + parameters: + - description: Request object + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.CreateUserRequest' + produces: + - application/json + responses: + "200": + description: Created user + schema: + $ref: '#/definitions/store.User' + "400": + description: Malformatted post user request | Invalid user create format + "401": + description: Missing auth session | Unauthorized to create user + "403": + description: Could not create host user + "500": + description: Failed to find user by id | Failed to generate password hash + | Failed to create user | Failed to create activity + summary: Create a user + tags: + - user + /api/v1/user/{id}: + delete: + parameters: + - description: User ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: User deleted + schema: + type: boolean + "400": + description: 'ID is not a number: %s | Current session user not found with + ID: %d' + "401": + description: Missing user in session + "403": + description: Unauthorized to delete user + "500": + description: Failed to find user | Failed to delete user + summary: Delete a user + tags: + - user + get: + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Requested user + schema: + $ref: '#/definitions/store.User' + "400": + description: Malformatted user id + "404": + description: User not found + "500": + description: Failed to find user + summary: Get user by id + tags: + - user + patch: + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Patch request + in: body + name: patch + required: true + schema: + $ref: '#/definitions/v1.UpdateUserRequest' + produces: + - application/json + responses: + "200": + description: Updated user + schema: + $ref: '#/definitions/store.User' + "400": + description: 'ID is not a number: %s | Current session user not found with + ID: %d | Malformatted patch user request | Invalid update user request' + "401": + description: Missing user in session + "403": + description: Unauthorized to update user + "500": + description: Failed to find user | Failed to generate password hash | Failed + to patch user | Failed to find userSettingList + summary: Update a user + tags: + - user + /api/v1/user/me: + get: + produces: + - application/json + responses: + "200": + description: Current user + schema: + $ref: '#/definitions/store.User' + "401": + description: Missing auth session + "500": + description: Failed to find user | Failed to find userSettingList + security: + - ApiKeyAuth: [] + summary: Get current user + tags: + - user + /api/v1/user/name/{username}: + get: + parameters: + - description: Username + in: path + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: Requested user + schema: + $ref: '#/definitions/store.User' + "404": + description: User not found + "500": + description: Failed to find user + summary: Get user by username + tags: + - user + /api/v1/user/setting: + post: + consumes: + - application/json + parameters: + - description: Request object. + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.UpsertUserSettingRequest' + produces: + - application/json + responses: + "200": + description: Created user setting + schema: + $ref: '#/definitions/store.UserSetting' + "400": + description: Malformatted post user setting upsert request | Invalid user + setting format + "401": + description: Missing auth session + "500": + description: Failed to upsert user setting + security: + - ApiKeyAuth: [] + summary: Create user setting + tags: + - user-setting + /explore/rss.xml: + get: + produces: + - text/xml + responses: + "200": + description: RSS + "500": + description: Failed to get system customized profile | Failed to find memo + list | Failed to generate rss + summary: Get RSS + tags: + - rss + /o/get/httpmeta: + get: + parameters: + - description: Website URL + in: query + name: url + required: true + type: string + produces: + - application/json + responses: + "200": + description: Extracted metadata + schema: + $ref: '#/definitions/getter.HTMLMeta' + "400": + description: Missing website url | Wrong url + "406": + description: 'Failed to get website meta with url: %s' + summary: Get website metadata + tags: + - get + /o/get/image: + get: + parameters: + - description: Image url + in: query + name: url + required: true + type: string + produces: + - image/* + responses: + "200": + description: Image + "400": + description: 'Missing image url | Wrong url | Failed to get image url: %s' + "500": + description: Failed to write image blob + summary: Get image from URL + tags: + - get + /o/r/{resourceId}: + get: + description: '*Swagger UI may have problems displaying other file types than + images' + parameters: + - description: Resource ID + in: path + name: resourceId + required: true + type: integer + - description: Thumbnail + in: query + name: thumbnail + type: integer + produces: + - application/octet-stream + responses: + "200": + description: Requested resource + "400": + description: 'ID is not a number: %s | Failed to get resource visibility' + "401": + description: Resource visibility not match + "404": + description: 'Resource not found: %d' + "500": + description: 'Failed to find resource by ID: %v | Failed to open the local + resource: %s | Failed to read the local resource: %s' + summary: Stream a resource + tags: + - resource + /u/{id}/rss.xml: + get: + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - text/xml + responses: + "200": + description: RSS + "400": + description: User id is not a number + "500": + description: Failed to get system customized profile | Failed to find memo + list | Failed to generate rss + summary: Get RSS for a user + tags: + - rss +securityDefinitions: + ApiKeyAuth: + description: Insert your Open ID API Key here. + in: query + name: openId + type: apiKey +swagger: "2.0" diff --git a/api/v1/auth.go b/api/v1/auth.go index da9c803e..8d97e3c0 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -32,220 +32,269 @@ type SignUp struct { } func (s *APIV1Service) registerAuthRoutes(g *echo.Group) { - // POST /auth/signin - Sign in. - g.POST("/auth/signin", func(c echo.Context) error { - ctx := c.Request().Context() - signin := &SignIn{} - - disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ - Name: SystemSettingDisablePasswordLoginName.String(), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) - } - if disablePasswordLoginSystemSetting != nil { - disablePasswordLogin := false - err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) - } - if disablePasswordLogin { - return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated") - } - } + g.POST("/auth/signin", s.signIn) + g.POST("/auth/signin/sso", s.signInSSO) + g.POST("/auth/signout", s.signOut) + g.POST("/auth/signup", s.signUp) +} - if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err) - } +// signIn godoc +// +// @Summary Sign-in to memos. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body SignIn true "Sign-in object" +// @Success 200 {object} store.User "User information" +// @Failure 400 {object} nil "Malformatted signin request" +// @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again" +// @Failure 403 {object} nil "User has been archived with username %s" +// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity" +// @Router /api/v1/auth/signin [POST] +func (s *APIV1Service) signIn(c echo.Context) error { + ctx := c.Request().Context() + signin := &SignIn{} - user, err := s.Store.GetUser(ctx, &store.FindUser{ - Username: &signin.Username, - }) + disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ + Name: SystemSettingDisablePasswordLoginName.String(), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) + } + if disablePasswordLoginSystemSetting != nil { + disablePasswordLogin := false + err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again") + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) } - if user == nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again") - } else if user.RowStatus == store.Archived { - return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username)) + if disablePasswordLogin { + return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated") } + } - // Compare the stored hashed password, with the hashed version of the password that was received. - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil { - // If the two passwords don't match, return a 401 status. - return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again") - } + if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err) + } - if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) - } - if err := s.createAuthSignInActivity(c, user); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) - } - userMessage := convertUserFromStore(user) - return c.JSON(http.StatusOK, userMessage) + user, err := s.Store.GetUser(ctx, &store.FindUser{ + Username: &signin.Username, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again") + } + if user == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again") + } else if user.RowStatus == store.Archived { + return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username)) + } - // POST /auth/signin/sso - Sign in with SSO - g.POST("/auth/signin/sso", func(c echo.Context) error { - ctx := c.Request().Context() - signin := &SSOSignIn{} - if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err) - } + // Compare the stored hashed password, with the hashed version of the password that was received. + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil { + // If the two passwords don't match, return a 401 status. + return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again") + } - identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{ - ID: &signin.IdentityProviderID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err) - } - if identityProvider == nil { - return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found") - } + if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) + } + if err := s.createAuthSignInActivity(c, user); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + userMessage := convertUserFromStore(user) + return c.JSON(http.StatusOK, userMessage) +} - var userInfo *idp.IdentityProviderUserInfo - if identityProvider.Type == store.IdentityProviderOAuth2Type { - oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.OAuth2Config) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err) - } - token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err) - } - userInfo, err = oauth2IdentityProvider.UserInfo(token) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err) - } - } +// signInSSO godoc +// +// @Summary Sign-in to memos using SSO. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body SSOSignIn true "SSO sign-in object" +// @Success 200 {object} store.User "User information" +// @Failure 400 {object} nil "Malformatted signin request" +// @Failure 401 {object} nil "Access denied, identifier does not match the filter." +// @Failure 403 {object} nil "User has been archived with username {username}" +// @Failure 404 {object} nil "Identity provider not found" +// @Failure 500 {object} nil "Failed to find identity provider | Failed to create identity provider instance | Failed to exchange token | Failed to get user info | Failed to compile identifier filter | Incorrect login credentials, please try again | Failed to generate random password | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity" +// @Router /api/v1/auth/signin/sso [POST] +func (s *APIV1Service) signInSSO(c echo.Context) error { + ctx := c.Request().Context() + signin := &SSOSignIn{} + if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err) + } - identifierFilter := identityProvider.IdentifierFilter - if identifierFilter != "" { - identifierFilterRegex, err := regexp.Compile(identifierFilter) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err) - } - if !identifierFilterRegex.MatchString(userInfo.Identifier) { - return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err) - } - } + identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{ + ID: &signin.IdentityProviderID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err) + } + if identityProvider == nil { + return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found") + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - Username: &userInfo.Identifier, - }) + var userInfo *idp.IdentityProviderUserInfo + if identityProvider.Type == store.IdentityProviderOAuth2Type { + oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.OAuth2Config) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again") + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err) } - if user == nil { - userCreate := &store.User{ - Username: userInfo.Identifier, - // The new signup user should be normal user by default. - Role: store.RoleUser, - Nickname: userInfo.DisplayName, - Email: userInfo.Email, - OpenID: util.GenUUID(), - } - password, err := util.RandomString(20) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err) - } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) - } - userCreate.PasswordHash = string(passwordHash) - user, err = s.Store.CreateUser(ctx, userCreate) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) - } - } - if user.RowStatus == store.Archived { - return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier)) - } - - if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) - } - if err := s.createAuthSignInActivity(c, user); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err) } - userMessage := convertUserFromStore(user) - return c.JSON(http.StatusOK, userMessage) - }) - - // POST /auth/signup - Sign up a new user. - g.POST("/auth/signup", func(c echo.Context) error { - ctx := c.Request().Context() - signup := &SignUp{} - if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err) + userInfo, err = oauth2IdentityProvider.UserInfo(token) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err) } + } - hostUserType := store.RoleHost - existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{ - Role: &hostUserType, - }) + identifierFilter := identityProvider.IdentifierFilter + if identifierFilter != "" { + identifierFilterRegex, err := regexp.Compile(identifierFilter) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err) } + if !identifierFilterRegex.MatchString(userInfo.Identifier) { + return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err) + } + } + user, err := s.Store.GetUser(ctx, &store.FindUser{ + Username: &userInfo.Identifier, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again") + } + if user == nil { userCreate := &store.User{ - Username: signup.Username, + Username: userInfo.Identifier, // The new signup user should be normal user by default. Role: store.RoleUser, - Nickname: signup.Username, + Nickname: userInfo.DisplayName, + Email: userInfo.Email, OpenID: util.GenUUID(), } - if len(existedHostUsers) == 0 { - // Change the default role to host if there is no host user. - userCreate.Role = store.RoleHost - } else { - allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ - Name: SystemSettingAllowSignUpName.String(), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) - } - - allowSignUpSettingValue := false - if allowSignUpSetting != nil { - err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err) - } - } - if !allowSignUpSettingValue { - return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err) - } + password, err := util.RandomString(20) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err) } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost) + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) } - userCreate.PasswordHash = string(passwordHash) - user, err := s.Store.CreateUser(ctx, userCreate) + user, err = s.Store.CreateUser(ctx, userCreate) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) } - if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) + } + if user.RowStatus == store.Archived { + return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier)) + } + + if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) + } + if err := s.createAuthSignInActivity(c, user); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + userMessage := convertUserFromStore(user) + return c.JSON(http.StatusOK, userMessage) +} + +// signOut godoc +// +// @Summary Sign-out from memos. +// @Tags auth +// @Produce json +// @Success 200 {boolean} true "Sign-out success" +// @Router /api/v1/auth/signout [POST] +func (*APIV1Service) signOut(c echo.Context) error { + RemoveTokensAndCookies(c) + return c.JSON(http.StatusOK, true) +} + +// signUp godoc +// +// @Summary Sign-up to memos. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body SignUp true "Sign-up object" +// @Success 200 {object} store.User "User information" +// @Failure 400 {object} nil "Malformatted signup request | Failed to find users" +// @Failure 401 {object} nil "signup is disabled" +// @Failure 403 {object} nil "Forbidden" +// @Failure 404 {object} nil "Not found" +// @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting allow signup | Failed to generate password hash | Failed to create user | Failed to generate tokens | Failed to create activity" +// @Router /api/v1/auth/signup [POST] +func (s *APIV1Service) signUp(c echo.Context) error { + ctx := c.Request().Context() + signup := &SignUp{} + if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err) + } + + hostUserType := store.RoleHost + existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{ + Role: &hostUserType, + }) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err) + } + + userCreate := &store.User{ + Username: signup.Username, + // The new signup user should be normal user by default. + Role: store.RoleUser, + Nickname: signup.Username, + OpenID: util.GenUUID(), + } + if len(existedHostUsers) == 0 { + // Change the default role to host if there is no host user. + userCreate.Role = store.RoleHost + } else { + allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ + Name: SystemSettingAllowSignUpName.String(), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) } - if err := s.createAuthSignUpActivity(c, user); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + + allowSignUpSettingValue := false + if allowSignUpSetting != nil { + err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err) + } + } + if !allowSignUpSettingValue { + return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err) } + } - userMessage := convertUserFromStore(user) - return c.JSON(http.StatusOK, userMessage) - }) + passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) + } - // POST /auth/signout - Sign out. - g.POST("/auth/signout", func(c echo.Context) error { - RemoveTokensAndCookies(c) - return c.JSON(http.StatusOK, true) - }) + userCreate.PasswordHash = string(passwordHash) + user, err := s.Store.CreateUser(ctx, userCreate) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) + } + if err := GenerateTokensAndSetCookies(c, user, s.Secret); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err) + } + if err := s.createAuthSignUpActivity(c, user); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + + userMessage := convertUserFromStore(user) + return c.JSON(http.StatusOK, userMessage) } func (s *APIV1Service) createAuthSignInActivity(c echo.Context, user *store.User) error { diff --git a/api/v1/http_getter.go b/api/v1/http_getter.go index f98b0fda..e132fc4d 100644 --- a/api/v1/http_getter.go +++ b/api/v1/http_getter.go @@ -11,43 +11,67 @@ import ( func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) { // GET /get/httpmeta?url={url} - Get website meta. - g.GET("/get/httpmeta", func(c echo.Context) error { - urlStr := c.QueryParam("url") - if urlStr == "" { - return echo.NewHTTPError(http.StatusBadRequest, "Missing website url") - } - if _, err := url.Parse(urlStr); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err) - } - - htmlMeta, err := getter.GetHTMLMeta(urlStr) - if err != nil { - return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err) - } - return c.JSON(http.StatusOK, htmlMeta) - }) + g.GET("/get/httpmeta", httpmeta) // GET /get/image?url={url} - Get image. - g.GET("/get/image", func(c echo.Context) error { - urlStr := c.QueryParam("url") - if urlStr == "" { - return echo.NewHTTPError(http.StatusBadRequest, "Missing image url") - } - if _, err := url.Parse(urlStr); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err) - } - - image, err := getter.GetImage(urlStr) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err) - } - - c.Response().Writer.WriteHeader(http.StatusOK) - c.Response().Writer.Header().Set("Content-Type", image.Mediatype) - c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") - if _, err := c.Response().Writer.Write(image.Blob); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err) - } - return nil - }) + g.GET("/get/image", image) +} + +// httpmeta godoc +// +// @Summary Get website metadata +// @Tags get +// @Produce json +// @Param url query string true "Website URL" +// @Success 200 {object} getter.HTMLMeta "Extracted metadata" +// @Failure 400 {object} nil "Missing website url | Wrong url" +// @Failure 406 {object} nil "Failed to get website meta with url: %s" +// @Router /o/get/httpmeta [GET] +func httpmeta(c echo.Context) error { + urlStr := c.QueryParam("url") + if urlStr == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Missing website url") + } + if _, err := url.Parse(urlStr); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err) + } + + htmlMeta, err := getter.GetHTMLMeta(urlStr) + if err != nil { + return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err) + } + return c.JSON(http.StatusOK, htmlMeta) +} + +// image godoc +// +// @Summary Get image from URL +// @Tags get +// @Produce image/* +// @Param url query string true "Image url" +// @Success 200 {object} nil "Image" +// @Failure 400 {object} nil "Missing image url | Wrong url | Failed to get image url: %s" +// @Failure 500 {object} nil "Failed to write image blob" +// @Router /o/get/image [GET] +func image(c echo.Context) error { + urlStr := c.QueryParam("url") + if urlStr == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Missing image url") + } + if _, err := url.Parse(urlStr); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err) + } + + image, err := getter.GetImage(urlStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err) + } + + c.Response().Writer.WriteHeader(http.StatusOK) + c.Response().Writer.Header().Set("Content-Type", image.Mediatype) + c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") + if _, err := c.Response().Writer.Write(image.Blob); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err) + } + return nil } diff --git a/api/v1/idp.go b/api/v1/idp.go index 0b63f749..65360339 100644 --- a/api/v1/idp.go +++ b/api/v1/idp.go @@ -65,176 +65,246 @@ type UpdateIdentityProviderRequest struct { } func (s *APIV1Service) registerIdentityProviderRoutes(g *echo.Group) { - g.POST("/idp", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + g.GET("/idp", s.getIdentityProviderList) + g.POST("/idp", s.createIdentityProvider) + + g.GET("/idp/:idpId", s.getIdentityProvider) + g.DELETE("/idp/:idpId", s.deleteIdentityProvider) + g.PATCH("/idp/:idpId", s.updateIdentityProvider) +} +// getIdentityProviderList godoc +// +// @Summary Get a list of identity providers +// @Description *clientSecret is only available for host user +// @Tags idp +// @Produce json +// @Success 200 {object} []IdentityProvider "List of available identity providers" +// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user" +// @Router /api/v1/idp [GET] +func (s *APIV1Service) getIdentityProviderList(c echo.Context) error { + ctx := c.Request().Context() + list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err) + } + + userID, ok := c.Get(auth.UserIDContextKey).(int32) + isHostUser := false + if ok { user, err := s.Store.GetUser(ctx, &store.FindUser{ ID: &userID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + if user == nil || user.Role == store.RoleHost { + isHostUser = true } + } - identityProviderCreate := &CreateIdentityProviderRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(identityProviderCreate); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post identity provider request").SetInternal(err) + identityProviderList := []*IdentityProvider{} + for _, item := range list { + identityProvider := convertIdentityProviderFromStore(item) + // data desensitize + if !isHostUser { + identityProvider.Config.OAuth2Config.ClientSecret = "" } + identityProviderList = append(identityProviderList, identityProvider) + } + return c.JSON(http.StatusOK, identityProviderList) +} - identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{ - Name: identityProviderCreate.Name, - Type: store.IdentityProviderType(identityProviderCreate.Type), - IdentifierFilter: identityProviderCreate.IdentifierFilter, - Config: convertIdentityProviderConfigToStore(identityProviderCreate.Config), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err) - } - return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) +// createIdentityProvider godoc +// +// @Summary Create Identity Provider +// @Tags idp +// @Accept json +// @Produce json +// @Param body body CreateIdentityProviderRequest true "Identity provider information" +// @Success 200 {object} store.IdentityProvider "Identity provider information" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 400 {object} nil "Malformatted post identity provider request" +// @Failure 500 {object} nil "Failed to find user | Failed to create identity provider" +// @Security ApiKeyAuth +// @Router /api/v1/idp [POST] +func (s *APIV1Service) createIdentityProvider(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.PATCH("/idp/:idpId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + identityProviderCreate := &CreateIdentityProviderRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(identityProviderCreate); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post identity provider request").SetInternal(err) + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{ + Name: identityProviderCreate.Name, + Type: store.IdentityProviderType(identityProviderCreate.Type), + IdentifierFilter: identityProviderCreate.IdentifierFilter, + Config: convertIdentityProviderConfigToStore(identityProviderCreate.Config), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err) + } + return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) +} - identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) - } +// getIdentityProvider godoc +// +// @Summary Get an identity provider by ID +// @Tags idp +// @Accept json +// @Produce json +// @Param idpId path int true "Identity provider ID" +// @Success 200 {object} store.IdentityProvider "Requested identity provider" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 404 {object} nil "Identity provider not found" +// @Failure 500 {object} nil "Failed to find identity provider list | Failed to find user" +// @Security ApiKeyAuth +// @Router /api/v1/idp/{idpId} [GET] +func (s *APIV1Service) getIdentityProvider(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - identityProviderPatch := &UpdateIdentityProviderRequest{ - ID: identityProviderID, - } - if err := json.NewDecoder(c.Request().Body).Decode(identityProviderPatch); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch identity provider request").SetInternal(err) - } + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{ - ID: identityProviderPatch.ID, - Type: store.IdentityProviderType(identityProviderPatch.Type), - Name: identityProviderPatch.Name, - IdentifierFilter: identityProviderPatch.IdentifierFilter, - Config: convertIdentityProviderConfigToStore(identityProviderPatch.Config), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err) - } - return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) + identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) + } + identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{ + ID: &identityProviderID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get identity provider").SetInternal(err) + } + if identityProvider == nil { + return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found") + } - g.GET("/idp", func(c echo.Context) error { - ctx := c.Request().Context() - list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err) - } + return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) +} - userID, ok := c.Get(auth.UserIDContextKey).(int32) - isHostUser := false - if ok { - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role == store.RoleHost { - isHostUser = true - } - } +// deleteIdentityProvider godoc +// +// @Summary Delete an identity provider by ID +// @Tags idp +// @Accept json +// @Produce json +// @Param idpId path int true "Identity Provider ID" +// @Success 200 {boolean} true "Identity Provider deleted" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider" +// @Security ApiKeyAuth +// @Router /api/v1/idp/{idpId} [DELETE] +func (s *APIV1Service) deleteIdentityProvider(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - identityProviderList := []*IdentityProvider{} - for _, item := range list { - identityProvider := convertIdentityProviderFromStore(item) - // data desensitize - if !isHostUser { - identityProvider.Config.OAuth2Config.ClientSecret = "" - } - identityProviderList = append(identityProviderList, identityProvider) - } - return c.JSON(http.StatusOK, identityProviderList) + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.GET("/idp/:idpId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + if err = s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProviderID}); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete identity provider").SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) - } - identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{ - ID: &identityProviderID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get identity provider").SetInternal(err) - } - if identityProvider == nil { - return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found") - } +// updateIdentityProvider godoc +// +// @Summary Update an identity provider by ID +// @Tags idp +// @Accept json +// @Produce json +// @Param idpId path int true "Identity Provider ID" +// @Param body body UpdateIdentityProviderRequest true "Patched identity provider information" +// @Success 200 {object} store.IdentityProvider "Patched identity provider" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch identity provider request" +// @Failure 401 {object} nil "Missing user in session | Unauthorized +// @Failure 500 {object} nil "Failed to find user | Failed to patch identity provider" +// @Security ApiKeyAuth +// @Router /api/v1/idp/{idpId} [PATCH] +func (s *APIV1Service) updateIdentityProvider(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.DELETE("/idp/:idpId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) + } - identityProviderID, err := util.ConvertStringToInt32(c.Param("idpId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err) - } + identityProviderPatch := &UpdateIdentityProviderRequest{ + ID: identityProviderID, + } + if err := json.NewDecoder(c.Request().Body).Decode(identityProviderPatch); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch identity provider request").SetInternal(err) + } - if err = s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProviderID}); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete identity provider").SetInternal(err) - } - return c.JSON(http.StatusOK, true) + identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{ + ID: identityProviderPatch.ID, + Type: store.IdentityProviderType(identityProviderPatch.Type), + Name: identityProviderPatch.Name, + IdentifierFilter: identityProviderPatch.IdentifierFilter, + Config: convertIdentityProviderConfigToStore(identityProviderPatch.Config), }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err) + } + return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider)) } func convertIdentityProviderFromStore(identityProvider *store.IdentityProvider) *IdentityProvider { diff --git a/api/v1/memo.go b/api/v1/memo.go index ff1410bb..c8247c89 100644 --- a/api/v1/memo.go +++ b/api/v1/memo.go @@ -113,496 +113,617 @@ type FindMemoRequest struct { const maxContentLength = 1 << 30 func (s *APIV1Service) registerMemoRoutes(g *echo.Group) { - g.POST("/memo", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + g.GET("/memo", s.getMemoList) + g.POST("/memo", s.createMemo) + g.GET("/memo/all", s.getAllMemos) + g.GET("/memo/stats", s.getMemoStats) + + g.GET("/memo/:memoId", s.getMemo) + g.DELETE("/memo/:memoId", s.deleteMemo) + g.PATCH("/memo/:memoId", s.updateMemo) +} - createMemoRequest := &CreateMemoRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err) - } - if len(createMemoRequest.Content) > maxContentLength { - return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB") - } +// getMemoList godoc +// +// @Summary Get a list of memos matching optional filters +// @Tags memo +// @Produce json +// @Param creatorId query int false "Creator ID" +// @Param creatorUsername query string false "Creator username" +// @Param rowStatus query store.RowStatus false "Row status" +// @Param pinned query bool false "Pinned" +// @Param tag query string false "Search for tag. Do not append #" +// @Param content query string false "Search for content" +// @Param limit query int false "Limit" +// @Param offset query int false "Offset" +// @Success 200 {object} []store.Memo "Memo list" +// @Failure 400 {object} nil "Missing user to find memo" +// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch memo list | Failed to compose memo response" +// @Security ApiKeyAuth +// @Router /api/v1/memo [GET] +func (s *APIV1Service) getMemoList(c echo.Context) error { + ctx := c.Request().Context() + findMemoMessage := &store.FindMemo{} + if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { + findMemoMessage.CreatorID = &userID + } - if createMemoRequest.Visibility == "" { - userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{ - UserID: &userID, - Key: UserSettingMemoVisibilityKey.String(), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err) - } - if userMemoVisibilitySetting != nil { - memoVisibility := Private - err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err) - } - createMemoRequest.Visibility = memoVisibility - } else { - // Private is the default memo visibility. - createMemoRequest.Visibility = Private - } + if username := c.QueryParam("creatorUsername"); username != "" { + user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) + if user != nil { + findMemoMessage.CreatorID = &user.ID } + } - // Find disable public memos system setting. - disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ - Name: SystemSettingDisablePublicMemosName.String(), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) - } - if disablePublicMemosSystemSetting != nil { - disablePublicMemos := false - err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) - } - if disablePublicMemos { - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil { - return echo.NewHTTPError(http.StatusNotFound, "User not found") - } - // Enforce normal user to create private memo if public memos are disabled. - if user.Role == store.RoleUser { - createMemoRequest.Visibility = Private - } - } + currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + // Anonymous use should only fetch PUBLIC memos with specified user + if findMemoMessage.CreatorID == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo") } + findMemoMessage.VisibilityList = []store.Visibility{store.Public} + } else { + // Authorized user can fetch all PUBLIC/PROTECTED memo + visibilityList := []store.Visibility{store.Public, store.Protected} - createMemoRequest.CreatorID = userID - memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err) - } - if err := s.createMemoCreateActivity(ctx, memo); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + // If Creator is authorized user (as default), PRIVATE memo is OK + if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID { + findMemoMessage.CreatorID = ¤tUserID + visibilityList = append(visibilityList, store.Private) } + findMemoMessage.VisibilityList = visibilityList + } - for _, resourceID := range createMemoRequest.ResourceIDList { - if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ - MemoID: memo.ID, - ResourceID: resourceID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) - } - } + rowStatus := store.RowStatus(c.QueryParam("rowStatus")) + if rowStatus != "" { + findMemoMessage.RowStatus = &rowStatus + } + pinnedStr := c.QueryParam("pinned") + if pinnedStr != "" { + pinned := pinnedStr == "true" + findMemoMessage.Pinned = &pinned + } - for _, memoRelationUpsert := range createMemoRequest.RelationList { - if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ - MemoID: memo.ID, - RelatedMemoID: memoRelationUpsert.RelatedMemoID, - Type: store.MemoRelationType(memoRelationUpsert.Type), - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) - } - } + contentSearch := []string{} + tag := c.QueryParam("tag") + if tag != "" { + contentSearch = append(contentSearch, "#"+tag) + } + contentSlice := c.QueryParams()["content"] + if len(contentSlice) > 0 { + contentSearch = append(contentSearch, contentSlice...) + } + findMemoMessage.ContentSearch = contentSearch - memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memo.ID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID)) - } + if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { + findMemoMessage.Limit = &limit + } + if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { + findMemoMessage.Offset = &offset + } + + memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) + } + if memoDisplayWithUpdatedTs { + findMemoMessage.OrderByUpdatedTs = true + } + list, err := s.Store.ListMemos(ctx, findMemoMessage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err) + } + memoResponseList := []*Memo{} + for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } - return c.JSON(http.StatusOK, memoResponse) - }) + memoResponseList = append(memoResponseList, memoResponse) + } + return c.JSON(http.StatusOK, memoResponseList) +} - g.PATCH("/memo/:memoId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } +// createMemo godoc +// +// @Summary Create a memo +// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE +// @Description *You should omit fields to use their default values +// @Tags memo +// @Accept json +// @Produce json +// @Param body body CreateMemoRequest true "Request object." +// @Success 200 {object} store.Memo "Stored memo" +// @Failure 400 {object} nil "Malformatted post memo request | Content size overflow, up to 1MB" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 404 {object} nil "User not found | Memo not found: %d" +// @Failure 500 {object} nil "Failed to find user setting | Failed to unmarshal user setting value | Failed to find system setting | Failed to unmarshal system setting | Failed to find user | Failed to create memo | Failed to create activity | Failed to upsert memo resource | Failed to upsert memo relation | Failed to compose memo | Failed to compose memo response" +// @Security ApiKeyAuth +// @Router /api/v1/memo [POST] +// +// NOTES: +// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo. +func (s *APIV1Service) createMemo(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } + createMemoRequest := &CreateMemoRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err) + } + if len(createMemoRequest.Content) > maxContentLength { + return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB") + } - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, + if createMemoRequest.Visibility == "" { + userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{ + UserID: &userID, + Key: UserSettingMemoVisibilityKey.String(), }) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) - } - if memo.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - currentTs := time.Now().Unix() - patchMemoRequest := &PatchMemoRequest{ - ID: memoID, - UpdatedTs: ¤tTs, - } - if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err) - } - - if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength { - return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err) - } - - updateMemoMessage := &store.UpdateMemo{ - ID: memoID, - CreatedTs: patchMemoRequest.CreatedTs, - UpdatedTs: patchMemoRequest.UpdatedTs, - Content: patchMemoRequest.Content, + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err) } - if patchMemoRequest.RowStatus != nil { - rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String()) - updateMemoMessage.RowStatus = &rowStatus - } - if patchMemoRequest.Visibility != nil { - visibility := store.Visibility(patchMemoRequest.Visibility.String()) - updateMemoMessage.Visibility = &visibility + if userMemoVisibilitySetting != nil { + memoVisibility := Private + err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err) + } + createMemoRequest.Visibility = memoVisibility + } else { + // Private is the default memo visibility. + createMemoRequest.Visibility = Private } + } - err = s.Store.UpdateMemo(ctx, updateMemoMessage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err) - } - memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) + // Find disable public memos system setting. + disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ + Name: SystemSettingDisablePublicMemosName.String(), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) + } + if disablePublicMemosSystemSetting != nil { + disablePublicMemos := false + err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) - } - - if patchMemoRequest.ResourceIDList != nil { - addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList) - for _, resourceID := range addedResourceIDList { - if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ - MemoID: memo.ID, - ResourceID: resourceID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) - } - } - for _, resourceID := range removedResourceIDList { - if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ - MemoID: &memo.ID, - ResourceID: &resourceID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err) - } - } + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) } - - if patchMemoRequest.RelationList != nil { - patchMemoRelationList := make([]*store.MemoRelation, 0) - for _, memoRelation := range patchMemoRequest.RelationList { - patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{ - MemoID: memo.ID, - RelatedMemoID: memoRelation.RelatedMemoID, - Type: store.MemoRelationType(memoRelation.Type), - }) + if disablePublicMemos { + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) } - addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList) - for _, memoRelation := range addedMemoRelationList { - if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) - } + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") } - for _, memoRelation := range removedMemoRelationList { - if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ - MemoID: &memo.ID, - RelatedMemoID: &memoRelation.RelatedMemoID, - Type: &memoRelation.Type, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) - } + // Enforce normal user to create private memo if public memos are disabled. + if user.Role == store.RoleUser { + createMemoRequest.Visibility = Private } } + } - memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) - } + createMemoRequest.CreatorID = userID + memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err) + } + if err := s.createMemoCreateActivity(ctx, memo); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } - memoResponse, err := s.convertMemoFromStore(ctx, memo) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + for _, resourceID := range createMemoRequest.ResourceIDList { + if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ + MemoID: memo.ID, + ResourceID: resourceID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } - return c.JSON(http.StatusOK, memoResponse) - }) + } - g.GET("/memo", func(c echo.Context) error { - ctx := c.Request().Context() - findMemoMessage := &store.FindMemo{} - if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { - findMemoMessage.CreatorID = &userID + for _, memoRelationUpsert := range createMemoRequest.RelationList { + if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memo.ID, + RelatedMemoID: memoRelationUpsert.RelatedMemoID, + Type: store.MemoRelationType(memoRelationUpsert.Type), + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) } + } - if username := c.QueryParam("creatorUsername"); username != "" { - user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) - if user != nil { - findMemoMessage.CreatorID = &user.ID - } - } + memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memo.ID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID)) + } - currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - // Anonymous use should only fetch PUBLIC memos with specified user - if findMemoMessage.CreatorID == nil { - return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo") - } - findMemoMessage.VisibilityList = []store.Visibility{store.Public} - } else { - // Authorized user can fetch all PUBLIC/PROTECTED memo - visibilityList := []store.Visibility{store.Public, store.Protected} + memoResponse, err := s.convertMemoFromStore(ctx, memo) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + } + return c.JSON(http.StatusOK, memoResponse) +} - // If Creator is authorized user (as default), PRIVATE memo is OK - if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID { - findMemoMessage.CreatorID = ¤tUserID - visibilityList = append(visibilityList, store.Private) - } - findMemoMessage.VisibilityList = visibilityList - } +// getAllMemos godoc +// +// @Summary Get a list of public memos matching optional filters +// @Description This should also list protected memos if the user is logged in +// @Description Authentication is optional +// @Tags memo +// @Produce json +// @Param limit query int false "Limit" +// @Param offset query int false "Offset" +// @Success 200 {object} []store.Memo "Memo list" +// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch all memo list | Failed to compose memo response" +// @Security ApiKeyAuth +// @Router /api/v1/memo/all [GET] +// +// NOTES: +// - creatorUsername is listed at ./web/src/helpers/api.ts:82, but it's not present here +func (s *APIV1Service) getAllMemos(c echo.Context) error { + ctx := c.Request().Context() + findMemoMessage := &store.FindMemo{} + _, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + findMemoMessage.VisibilityList = []store.Visibility{store.Public} + } else { + findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} + } - rowStatus := store.RowStatus(c.QueryParam("rowStatus")) - if rowStatus != "" { - findMemoMessage.RowStatus = &rowStatus - } - pinnedStr := c.QueryParam("pinned") - if pinnedStr != "" { - pinned := pinnedStr == "true" - findMemoMessage.Pinned = &pinned - } + if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { + findMemoMessage.Limit = &limit + } + if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { + findMemoMessage.Offset = &offset + } - contentSearch := []string{} - tag := c.QueryParam("tag") - if tag != "" { - contentSearch = append(contentSearch, "#"+tag) - } - contentSlice := c.QueryParams()["content"] - if len(contentSlice) > 0 { - contentSearch = append(contentSearch, contentSlice...) - } - findMemoMessage.ContentSearch = contentSearch + // Only fetch normal status memos. + normalStatus := store.Normal + findMemoMessage.RowStatus = &normalStatus - if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { - findMemoMessage.Limit = &limit - } - if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { - findMemoMessage.Offset = &offset - } + memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) + } + if memoDisplayWithUpdatedTs { + findMemoMessage.OrderByUpdatedTs = true + } - memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) + list, err := s.Store.ListMemos(ctx, findMemoMessage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err) + } + memoResponseList := []*Memo{} + for _, memo := range list { + memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) - } - if memoDisplayWithUpdatedTs { - findMemoMessage.OrderByUpdatedTs = true + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } + memoResponseList = append(memoResponseList, memoResponse) + } + return c.JSON(http.StatusOK, memoResponseList) +} - list, err := s.Store.ListMemos(ctx, findMemoMessage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err) - } - memoResponseList := []*Memo{} - for _, memo := range list { - memoResponse, err := s.convertMemoFromStore(ctx, memo) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) - } - memoResponseList = append(memoResponseList, memoResponse) - } - return c.JSON(http.StatusOK, memoResponseList) - }) +// getMemoStats godoc +// +// @Summary Get memo stats by creator ID or username +// @Description Used to generate the heatmap +// @Tags memo +// @Produce json +// @Param creatorId query int false "Creator ID" +// @Param creatorUsername query string false "Creator username" +// @Success 200 {object} []int "Memo createdTs list" +// @Failure 400 {object} nil "Missing user id to find memo" +// @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to find memo list | Failed to compose memo response" +// @Router /api/v1/memo/stats [GET] +func (s *APIV1Service) getMemoStats(c echo.Context) error { + ctx := c.Request().Context() + normalStatus := store.Normal + findMemoMessage := &store.FindMemo{ + RowStatus: &normalStatus, + } + if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { + findMemoMessage.CreatorID = &creatorID + } - g.GET("/memo/:memoId", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + if username := c.QueryParam("creatorUsername"); username != "" { + user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) + if user != nil { + findMemoMessage.CreatorID = &user.ID } + } - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) - } + if findMemoMessage.CreatorID == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo") + } - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if memo.Visibility == store.Private { - if !ok || memo.CreatorID != userID { - return echo.NewHTTPError(http.StatusForbidden, "this memo is private only") - } - } else if memo.Visibility == store.Protected { - if !ok { - return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session") - } + currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + findMemoMessage.VisibilityList = []store.Visibility{store.Public} + } else { + if *findMemoMessage.CreatorID != currentUserID { + findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} + } else { + findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private} } + } + + memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) + } + if memoDisplayWithUpdatedTs { + findMemoMessage.OrderByUpdatedTs = true + } + + list, err := s.Store.ListMemos(ctx, findMemoMessage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) + } + memoResponseList := []*Memo{} + for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } - return c.JSON(http.StatusOK, memoResponse) + memoResponseList = append(memoResponseList, memoResponse) + } + + displayTsList := []int64{} + for _, memo := range memoResponseList { + displayTsList = append(displayTsList, memo.DisplayTs) + } + return c.JSON(http.StatusOK, displayTsList) +} + +// getMemo godoc +// +// @Summary Get memo by ID +// @Tags memo +// @Produce json +// @Param memoId path int true "Memo ID" +// @Success 200 {object} []store.Memo "Memo list" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 403 {object} nil "this memo is private only | this memo is protected, missing user in session +// @Failure 404 {object} nil "Memo not found: %d" +// @Failure 500 {object} nil "Failed to find memo by ID: %v | Failed to compose memo response" +// @Router /api/v1/memo/{memoId} [GET] +func (s *APIV1Service) getMemo(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } + + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + } - g.GET("/memo/stats", func(c echo.Context) error { - ctx := c.Request().Context() - normalStatus := store.Normal - findMemoMessage := &store.FindMemo{ - RowStatus: &normalStatus, + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if memo.Visibility == store.Private { + if !ok || memo.CreatorID != userID { + return echo.NewHTTPError(http.StatusForbidden, "this memo is private only") } - if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { - findMemoMessage.CreatorID = &creatorID + } else if memo.Visibility == store.Protected { + if !ok { + return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session") } + } + memoResponse, err := s.convertMemoFromStore(ctx, memo) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + } + return c.JSON(http.StatusOK, memoResponse) +} - if username := c.QueryParam("creatorUsername"); username != "" { - user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) - if user != nil { - findMemoMessage.CreatorID = &user.ID - } - } +// deleteMemo godoc +// +// @Summary Delete memo by ID +// @Tags memo +// @Produce json +// @Param memoId path int true "Memo ID to delete" +// @Success 200 {boolean} true "Memo deleted" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 404 {object} nil "Memo not found: %d" +// @Failure 500 {object} nil "Failed to find memo | Failed to delete memo ID: %v" +// @Security ApiKeyAuth +// @Router /api/v1/memo/{memoId} [DELETE] +func (s *APIV1Service) deleteMemo(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - if findMemoMessage.CreatorID == nil { - return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo") - } + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + } + if memo.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - findMemoMessage.VisibilityList = []store.Visibility{store.Public} - } else { - if *findMemoMessage.CreatorID != currentUserID { - findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} - } else { - findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private} - } - } + if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ + ID: memoID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) - } - if memoDisplayWithUpdatedTs { - findMemoMessage.OrderByUpdatedTs = true - } +// updateMemo godoc +// +// @Summary Update a memo +// @Description Visibility can be PUBLIC, PROTECTED or PRIVATE +// @Description *You should omit fields to use their default values +// @Tags memo +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to update" +// @Param body body PatchMemoRequest true "Patched object." +// @Success 200 {object} store.Memo "Stored memo" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch memo request | Content size overflow, up to 1MB" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 404 {object} nil "Memo not found: %d" +// @Failure 500 {object} nil "Failed to find memo | Failed to patch memo | Failed to upsert memo resource | Failed to delete memo resource | Failed to compose memo response" +// @Security ApiKeyAuth +// @Router /api/v1/memo/{memoId} [PATCH] +// +// NOTES: +// - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo. +// - Passing 0 to createdTs and updatedTs will set them to 0 in the database, which is probably unwanted. +func (s *APIV1Service) updateMemo(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - list, err := s.Store.ListMemos(ctx, findMemoMessage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) - } - memoResponseList := []*Memo{} - for _, memo := range list { - memoResponse, err := s.convertMemoFromStore(ctx, memo) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) - } - memoResponseList = append(memoResponseList, memoResponse) - } + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - displayTsList := []int64{} - for _, memo := range memoResponseList { - displayTsList = append(displayTsList, memo.DisplayTs) - } - return c.JSON(http.StatusOK, displayTsList) + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + } + if memo.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.GET("/memo/all", func(c echo.Context) error { - ctx := c.Request().Context() - findMemoMessage := &store.FindMemo{} - _, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - findMemoMessage.VisibilityList = []store.Visibility{store.Public} - } else { - findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} - } + currentTs := time.Now().Unix() + patchMemoRequest := &PatchMemoRequest{ + ID: memoID, + UpdatedTs: ¤tTs, + } + if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err) + } - if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { - findMemoMessage.Limit = &limit - } - if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { - findMemoMessage.Offset = &offset - } + if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength { + return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err) + } - // Only fetch normal status memos. - normalStatus := store.Normal - findMemoMessage.RowStatus = &normalStatus + updateMemoMessage := &store.UpdateMemo{ + ID: memoID, + CreatedTs: patchMemoRequest.CreatedTs, + UpdatedTs: patchMemoRequest.UpdatedTs, + Content: patchMemoRequest.Content, + } + if patchMemoRequest.RowStatus != nil { + rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String()) + updateMemoMessage.RowStatus = &rowStatus + } + if patchMemoRequest.Visibility != nil { + visibility := store.Visibility(patchMemoRequest.Visibility.String()) + updateMemoMessage.Visibility = &visibility + } - memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) - } - if memoDisplayWithUpdatedTs { - findMemoMessage.OrderByUpdatedTs = true - } + err = s.Store.UpdateMemo(ctx, updateMemoMessage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err) + } + memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + } - list, err := s.Store.ListMemos(ctx, findMemoMessage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err) - } - memoResponseList := []*Memo{} - for _, memo := range list { - memoResponse, err := s.convertMemoFromStore(ctx, memo) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + if patchMemoRequest.ResourceIDList != nil { + addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList) + for _, resourceID := range addedResourceIDList { + if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ + MemoID: memo.ID, + ResourceID: resourceID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } - memoResponseList = append(memoResponseList, memoResponse) } - return c.JSON(http.StatusOK, memoResponseList) - }) - - g.DELETE("/memo/:memoId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + for _, resourceID := range removedResourceIDList { + if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ + MemoID: &memo.ID, + ResourceID: &resourceID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err) + } } + } - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + if patchMemoRequest.RelationList != nil { + patchMemoRelationList := make([]*store.MemoRelation, 0) + for _, memoRelation := range patchMemoRequest.RelationList { + patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{ + MemoID: memo.ID, + RelatedMemoID: memoRelation.RelatedMemoID, + Type: store.MemoRelationType(memoRelation.Type), + }) } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList) + for _, memoRelation := range addedMemoRelationList { + if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) + } } - if memo.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + for _, memoRelation := range removedMemoRelationList { + if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &memo.ID, + RelatedMemoID: &memoRelation.RelatedMemoID, + Type: &memoRelation.Type, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) + } } + } - if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ - ID: memoID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err) - } - return c.JSON(http.StatusOK, true) - }) + memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) + } + + memoResponse, err := s.convertMemoFromStore(ctx, memo) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + } + return c.JSON(http.StatusOK, memoResponse) } func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error { diff --git a/api/v1/memo_organizer.go b/api/v1/memo_organizer.go index a55b7ffd..b78b8f95 100644 --- a/api/v1/memo_organizer.go +++ b/api/v1/memo_organizer.go @@ -22,60 +22,77 @@ type UpsertMemoOrganizerRequest struct { } func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) { - g.POST("/memo/:memoId/organizer", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } + g.POST("/memo/:memoId/organizer", s.organizeMemo) +} - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } +// organizeMemo godoc +// +// @Summary Organize memo (pin/unpin) +// @Tags memo-organizer +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to organize" +// @Param body body UpsertMemoOrganizerRequest true "Memo organizer object" +// @Success 200 {object} store.Memo "Memo information" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo organizer request" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 404 {object} nil "Memo not found: %v" +// @Failure 500 {object} nil "Failed to find memo | Failed to upsert memo organizer | Failed to find memo by ID: %v | Failed to compose memo response" +// @Security ApiKeyAuth +// @Router /api/v1/memo/{memoId}/organizer [POST] +func (s *APIV1Service) organizeMemo(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID)) - } - if memo.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - request := &UpsertMemoOrganizerRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err) - } + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID)) + } + if memo.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - upsert := &store.MemoOrganizer{ - MemoID: memoID, - UserID: userID, - Pinned: request.Pinned, - } - _, err = s.Store.UpsertMemoOrganizer(ctx, upsert) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err) - } + request := &UpsertMemoOrganizerRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err) + } - memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID)) - } + upsert := &store.MemoOrganizer{ + MemoID: memoID, + UserID: userID, + Pinned: request.Pinned, + } + _, err = s.Store.UpsertMemoOrganizer(ctx, upsert) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err) + } - memoResponse, err := s.convertMemoFromStore(ctx, memo) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) - } - return c.JSON(http.StatusOK, memoResponse) + memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID)) + } + + memoResponse, err := s.convertMemoFromStore(ctx, memo) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) + } + return c.JSON(http.StatusOK, memoResponse) } diff --git a/api/v1/memo_relation.go b/api/v1/memo_relation.go index 63e94d56..0c80821b 100644 --- a/api/v1/memo_relation.go +++ b/api/v1/memo_relation.go @@ -29,66 +29,117 @@ type UpsertMemoRelationRequest struct { } func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) { - g.POST("/memo/:memoId/relation", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } + g.GET("/memo/:memoId/relation", s.getMemoRelationList) + g.POST("/memo/:memoId/relation", s.createMemoRelation) + g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", s.deleteMemoRelation) +} - request := &UpsertMemoRelationRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err) - } +// getMemoRelationList godoc +// +// @Summary Get a list of Memo Relations +// @Tags memo-relation +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to find relations" +// @Success 200 {object} []store.MemoRelation "Memo relation information list" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 500 {object} nil "Failed to list memo relations" +// @Router /api/v1/memo/{memoId}/relation [GET] +func (s *APIV1Service) getMemoRelationList(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ - MemoID: memoID, - RelatedMemoID: request.RelatedMemoID, - Type: store.MemoRelationType(request.Type), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) - } - return c.JSON(http.StatusOK, memoRelation) + memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err) + } + return c.JSON(http.StatusOK, memoRelationList) +} - g.GET("/memo/:memoId/relation", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } +// createMemoRelation godoc +// +// @Summary Create Memo Relation +// @Description Create a relation between two memos +// @Tags memo-relation +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to relate" +// @Param body body UpsertMemoRelationRequest true "Memo relation object" +// @Success 200 {object} store.MemoRelation "Memo relation information" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo relation request" +// @Failure 500 {object} nil "Failed to upsert memo relation" +// @Router /api/v1/memo/{memoId}/relation [POST] +// +// NOTES: +// - Currently not secured +// - It's possible to create relations to memos that doesn't exist, which will trigger 404 errors when the frontend tries to load them. +// - It's possible to create multiple relations, though the interface only shows first. +func (s *APIV1Service) createMemoRelation(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } + + request := &UpsertMemoRelationRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err) + } - memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ - MemoID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err) - } - return c.JSON(http.StatusOK, memoRelationList) + memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memoID, + RelatedMemoID: request.RelatedMemoID, + Type: store.MemoRelationType(request.Type), }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) + } + return c.JSON(http.StatusOK, memoRelation) +} - g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } - relatedMemoID, err := util.ConvertStringToInt32(c.Param("relatedMemoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } - relationType := store.MemoRelationType(c.Param("relationType")) +// deleteMemoRelation godoc +// +// @Summary Delete a Memo Relation +// @Description Removes a relation between two memos +// @Tags memo-relation +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to find relations" +// @Param relatedMemoId path int true "ID of memo to remove relation to" +// @Param relationType path MemoRelationType true "Type of relation to remove" +// @Success 200 {boolean} true "Memo relation deleted" +// @Failure 400 {object} nil "Memo ID is not a number: %s | Related memo ID is not a number: %s" +// @Failure 500 {object} nil "Failed to delete memo relation" +// @Router /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType} [DELETE] +// +// NOTES: +// - Currently not secured. +// - Will always return true, even if the relation doesn't exist. +func (s *APIV1Service) deleteMemoRelation(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } + relatedMemoID, err := util.ConvertStringToInt32(c.Param("relatedMemoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("relatedMemoId"))).SetInternal(err) + } + relationType := store.MemoRelationType(c.Param("relationType")) - if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ - MemoID: &memoID, - RelatedMemoID: &relatedMemoID, - Type: &relationType, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) - } - return c.JSON(http.StatusOK, true) - }) + if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &memoID, + RelatedMemoID: &relatedMemoID, + Type: &relationType, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) + } + return c.JSON(http.StatusOK, true) } func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *MemoRelation { diff --git a/api/v1/memo_resource.go b/api/v1/memo_resource.go index 26fa5692..08cbe00d 100644 --- a/api/v1/memo_resource.go +++ b/api/v1/memo_resource.go @@ -35,101 +35,147 @@ type MemoResourceDelete struct { } func (s *APIV1Service) registerMemoResourceRoutes(g *echo.Group) { - g.POST("/memo/:memoId/resource", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } + g.GET("/memo/:memoId/resource", s.getMemoResourceList) + g.POST("/memo/:memoId/resource", s.bindMemoResource) + g.DELETE("/memo/:memoId/resource/:resourceId", s.unbindMemoResource) +} - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - request := &UpsertMemoResourceRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err) - } - resource, err := s.Store.GetResource(ctx, &store.FindResource{ - ID: &request.ResourceID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err) - } - if resource == nil { - return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err) - } else if resource.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err) - } +// getMemoResourceList godoc +// +// @Summary Get resource list of a memo +// @Tags memo-resource +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to fetch resource list from" +// @Success 200 {object} []Resource "Memo resource list" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 500 {object} nil "Failed to fetch resource list" +// @Router /api/v1/memo/{memoId}/resource [GET] +func (s *APIV1Service) getMemoResourceList(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - upsert := &store.UpsertMemoResource{ - MemoID: memoID, - ResourceID: request.ResourceID, - CreatedTs: time.Now().Unix(), - } - if request.UpdatedTs != nil { - upsert.UpdatedTs = request.UpdatedTs - } - if _, err := s.Store.UpsertMemoResource(ctx, upsert); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) - } - return c.JSON(http.StatusOK, true) + list, err := s.Store.ListResources(ctx, &store.FindResource{ + MemoID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) + } + resourceList := []*Resource{} + for _, resource := range list { + resourceList = append(resourceList, convertResourceFromStore(resource)) + } + return c.JSON(http.StatusOK, resourceList) +} - g.GET("/memo/:memoId/resource", func(c echo.Context) error { - ctx := c.Request().Context() - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } +// bindMemoResource godoc +// +// @Summary Bind resource to memo +// @Tags memo-resource +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to bind resource to" +// @Param body body UpsertMemoResourceRequest true "Memo resource request object" +// @Success 200 {boolean} true "Memo resource binded" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted post memo resource request | Resource not found" +// @Failure 401 {object} nil "Missing user in session | Unauthorized to bind this resource" +// @Failure 500 {object} nil "Failed to fetch resource | Failed to upsert memo resource" +// @Security ApiKeyAuth +// @Router /api/v1/memo/{memoId}/resource [POST] +// +// NOTES: +// - Passing 0 to updatedTs will set it to 0 in the database, which is probably unwanted. +func (s *APIV1Service) bindMemoResource(c echo.Context) error { + ctx := c.Request().Context() + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } - list, err := s.Store.ListResources(ctx, &store.FindResource{ - MemoID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) - } - resourceList := []*Resource{} - for _, resource := range list { - resourceList = append(resourceList, convertResourceFromStore(resource)) - } - return c.JSON(http.StatusOK, resourceList) + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + request := &UpsertMemoResourceRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err) + } + resource, err := s.Store.GetResource(ctx, &store.FindResource{ + ID: &request.ResourceID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err) + } + if resource == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err) + } else if resource.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err) + } - g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - memoID, err := util.ConvertStringToInt32(c.Param("memoId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err) - } - resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } + upsert := &store.UpsertMemoResource{ + MemoID: memoID, + ResourceID: request.ResourceID, + CreatedTs: time.Now().Unix(), + } + if request.UpdatedTs != nil { + upsert.UpdatedTs = request.UpdatedTs + } + if _, err := s.Store.UpsertMemoResource(ctx, upsert); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ - ID: &memoID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) - } - if memo == nil { - return echo.NewHTTPError(http.StatusBadRequest, "Memo not found") - } - if memo.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } +// unbindMemoResource godoc +// +// @Summary Unbind resource from memo +// @Tags memo-resource +// @Accept json +// @Produce json +// @Param memoId path int true "ID of memo to unbind resource from" +// @Param resourceId path int true "ID of resource to unbind from memo" +// @Success 200 {boolean} true "Memo resource unbinded. *200 is returned even if the reference doesn't exists " +// @Failure 400 {object} nil "Memo ID is not a number: %s | Resource ID is not a number: %s | Memo not found" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find memo | Failed to fetch resource list" +// @Security ApiKeyAuth +// @Router /api/v1/memo/{memoId}/resource/{resourceId} [DELETE] +func (s *APIV1Service) unbindMemoResource(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + memoID, err := util.ConvertStringToInt32(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } + resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) + } - if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ - MemoID: &memoID, - ResourceID: &resourceID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) - } - return c.JSON(http.StatusOK, true) + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ + ID: &memoID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) + } + if memo == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Memo not found") + } + if memo.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } + + if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ + MemoID: &memoID, + ResourceID: &resourceID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) + } + return c.JSON(http.StatusOK, true) } diff --git a/api/v1/resource.go b/api/v1/resource.go index 9edb7031..c7c94d1b 100644 --- a/api/v1/resource.go +++ b/api/v1/resource.go @@ -81,331 +81,416 @@ const ( var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`) func (s *APIV1Service) registerResourceRoutes(g *echo.Group) { - g.POST("/resource", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + g.GET("/resource", s.getResourceList) + g.POST("/resource", s.createResource) + g.POST("/resource/blob", s.uploadResource) + g.DELETE("/resource/:resourceId", s.deleteResource) + g.PATCH("/resource/:resourceId", s.updateResource) +} - request := &CreateResourceRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err) - } +func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) { + g.GET("/r/:resourceId", s.streamResource) + g.GET("/r/:resourceId/*", s.streamResource) +} + +// getResourceList godoc +// +// @Summary Get a list of resources +// @Tags resource +// @Produce json +// @Param limit query int false "Limit" +// @Param offset query int false "Offset" +// @Success 200 {object} []store.Resource "Resource list" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to fetch resource list" +// @Security ApiKeyAuth +// @Router /api/v1/resource [GET] +func (s *APIV1Service) getResourceList(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + find := &store.FindResource{ + CreatorID: &userID, + } + if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { + find.Limit = &limit + } + if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { + find.Offset = &offset + } - create := &store.Resource{ - CreatorID: userID, - Filename: request.Filename, - ExternalLink: request.ExternalLink, - Type: request.Type, + list, err := s.Store.ListResources(ctx, find) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) + } + resourceMessageList := []*Resource{} + for _, resource := range list { + resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource)) + } + return c.JSON(http.StatusOK, resourceMessageList) +} + +// createResource godoc +// +// @Summary Create resource +// @Tags resource +// @Accept json +// @Produce json +// @Param body body CreateResourceRequest true "Request object." +// @Success 200 {object} store.Resource "Created resource" +// @Failure 400 {object} nil "Malformatted post resource request | Invalid external link | Invalid external link scheme | Failed to request %s | Failed to read %s | Failed to read mime from %s" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to save resource | Failed to create resource | Failed to create activity" +// @Security ApiKeyAuth +// @Router /api/v1/resource [POST] +func (s *APIV1Service) createResource(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + request := &CreateResourceRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err) + } + + create := &store.Resource{ + CreatorID: userID, + Filename: request.Filename, + ExternalLink: request.ExternalLink, + Type: request.Type, + } + if request.ExternalLink != "" { + // Only allow those external links scheme with http/https + linkURL, err := url.Parse(request.ExternalLink) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err) + } + if linkURL.Scheme != "http" && linkURL.Scheme != "https" { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme") } - if request.ExternalLink != "" { - // Only allow those external links scheme with http/https - linkURL, err := url.Parse(request.ExternalLink) + + if request.DownloadToLocal { + resp, err := http.Get(linkURL.String()) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err) - } - if linkURL.Scheme != "http" && linkURL.Scheme != "https" { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme") + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink)) } + defer resp.Body.Close() - if request.DownloadToLocal { - resp, err := http.Get(linkURL.String()) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink)) - } - defer resp.Body.Close() + blob, err := io.ReadAll(resp.Body) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink)) + } - blob, err := io.ReadAll(resp.Body) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink)) - } + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink)) + } + create.Type = mediaType - mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink)) - } - create.Type = mediaType - - filename := path.Base(linkURL.Path) - if path.Ext(filename) == "" { - extensions, _ := mime.ExtensionsByType(mediaType) - if len(extensions) > 0 { - filename += extensions[0] - } + filename := path.Base(linkURL.Path) + if path.Ext(filename) == "" { + extensions, _ := mime.ExtensionsByType(mediaType) + if len(extensions) > 0 { + filename += extensions[0] } - create.Filename = filename - create.ExternalLink = "" - create.Size = int64(len(blob)) + } + create.Filename = filename + create.ExternalLink = "" + create.Size = int64(len(blob)) - err = SaveResourceBlob(ctx, s.Store, create, bytes.NewReader(blob)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err) - } + err = SaveResourceBlob(ctx, s.Store, create, bytes.NewReader(blob)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err) } } + } - resource, err := s.Store.CreateResource(ctx, create) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err) - } - if err := s.createResourceCreateActivity(ctx, resource); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) - } - return c.JSON(http.StatusOK, convertResourceFromStore(resource)) - }) + resource, err := s.Store.CreateResource(ctx, create) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err) + } + if err := s.createResourceCreateActivity(ctx, resource); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + return c.JSON(http.StatusOK, convertResourceFromStore(resource)) +} - g.POST("/resource/blob", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } +// uploadResource godoc +// +// @Summary Upload resource +// @Tags resource +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "File to upload" +// @Success 200 {object} store.Resource "Created resource" +// @Failure 400 {object} nil "Upload file not found | File size exceeds allowed limit of %d MiB | Failed to parse upload data" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to get uploading file | Failed to open file | Failed to save resource | Failed to create resource | Failed to create activity" +// @Security ApiKeyAuth +// @Router /api/v1/resource/blob [POST] +func (s *APIV1Service) uploadResource(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - // This is the backend default max upload size limit. - maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32") - var settingMaxUploadSizeBytes int - if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil { - settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte - } else { - log.Warn("Failed to parse max upload size", zap.Error(err)) - settingMaxUploadSizeBytes = 0 - } + // This is the backend default max upload size limit. + maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32") + var settingMaxUploadSizeBytes int + if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil { + settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte + } else { + log.Warn("Failed to parse max upload size", zap.Error(err)) + settingMaxUploadSizeBytes = 0 + } - file, err := c.FormFile("file") - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err) - } - if file == nil { - return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err) - } + file, err := c.FormFile("file") + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err) + } + if file == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err) + } - if file.Size > int64(settingMaxUploadSizeBytes) { - message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte) - return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err) - } - if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err) - } + if file.Size > int64(settingMaxUploadSizeBytes) { + message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte) + return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err) + } + if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err) + } - sourceFile, err := file.Open() - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err) - } - defer sourceFile.Close() + sourceFile, err := file.Open() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err) + } + defer sourceFile.Close() - create := &store.Resource{ - CreatorID: userID, - Filename: file.Filename, - Type: file.Header.Get("Content-Type"), - Size: file.Size, - } - err = SaveResourceBlob(ctx, s.Store, create, sourceFile) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err) - } + create := &store.Resource{ + CreatorID: userID, + Filename: file.Filename, + Type: file.Header.Get("Content-Type"), + Size: file.Size, + } + err = SaveResourceBlob(ctx, s.Store, create, sourceFile) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err) + } - resource, err := s.Store.CreateResource(ctx, create) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err) - } - if err := s.createResourceCreateActivity(ctx, resource); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) - } - return c.JSON(http.StatusOK, convertResourceFromStore(resource)) - }) + resource, err := s.Store.CreateResource(ctx, create) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err) + } + if err := s.createResourceCreateActivity(ctx, resource); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + return c.JSON(http.StatusOK, convertResourceFromStore(resource)) +} - g.GET("/resource", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - find := &store.FindResource{ - CreatorID: &userID, - } - if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { - find.Limit = &limit - } - if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { - find.Offset = &offset - } +// deleteResource godoc +// +// @Summary Delete a resource +// @Tags resource +// @Produce json +// @Param resourceId path int true "Resource ID" +// @Success 200 {boolean} true "Resource deleted" +// @Failure 400 {object} nil "ID is not a number: %s" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 404 {object} nil "Resource not found: %d" +// @Failure 500 {object} nil "Failed to find resource | Failed to delete resource" +// @Security ApiKeyAuth +// @Router /api/v1/resource/{resourceId} [DELETE] +func (s *APIV1Service) deleteResource(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - list, err := s.Store.ListResources(ctx, find) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) - } - resourceMessageList := []*Resource{} - for _, resource := range list { - resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource)) - } - return c.JSON(http.StatusOK, resourceMessageList) + resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) + } + + resource, err := s.Store.GetResource(ctx, &store.FindResource{ + ID: &resourceID, + CreatorID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err) + } + if resource == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) + } - g.PATCH("/resource/:resourceId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + if resource.InternalPath != "" { + if err := os.Remove(resource.InternalPath); err != nil { + log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err)) } + } - resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } + ext := filepath.Ext(resource.Filename) + thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) + if err := os.Remove(thumbnailPath); err != nil { + log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err)) + } - resource, err := s.Store.GetResource(ctx, &store.FindResource{ - ID: &resourceID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err) - } - if resource == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) - } - if resource.CreatorID != userID { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ + ID: resourceID, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - request := &UpdateResourceRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err) - } +// updateResource godoc +// +// @Summary Update a resource +// @Tags resource +// @Produce json +// @Param resourceId path int true "Resource ID" +// @Param patch body UpdateResourceRequest true "Patch resource request" +// @Success 200 {object} store.Resource "Updated resource" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch resource request" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 404 {object} nil "Resource not found: %d" +// @Failure 500 {object} nil "Failed to find resource | Failed to patch resource" +// @Security ApiKeyAuth +// @Router /api/v1/resource/{resourceId} [PATCH] +func (s *APIV1Service) updateResource(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - currentTs := time.Now().Unix() - update := &store.UpdateResource{ - ID: resourceID, - UpdatedTs: ¤tTs, - } - if request.Filename != nil && *request.Filename != "" { - update.Filename = request.Filename - } + resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) + } - resource, err = s.Store.UpdateResource(ctx, update) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err) - } - return c.JSON(http.StatusOK, convertResourceFromStore(resource)) + resource, err := s.Store.GetResource(ctx, &store.FindResource{ + ID: &resourceID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err) + } + if resource == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) + } + if resource.CreatorID != userID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.DELETE("/resource/:resourceId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + request := &UpdateResourceRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err) + } - resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } + currentTs := time.Now().Unix() + update := &store.UpdateResource{ + ID: resourceID, + UpdatedTs: ¤tTs, + } + if request.Filename != nil && *request.Filename != "" { + update.Filename = request.Filename + } - resource, err := s.Store.GetResource(ctx, &store.FindResource{ - ID: &resourceID, - CreatorID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err) - } - if resource == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) - } + resource, err = s.Store.UpdateResource(ctx, update) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err) + } + return c.JSON(http.StatusOK, convertResourceFromStore(resource)) +} - if resource.InternalPath != "" { - if err := os.Remove(resource.InternalPath); err != nil { - log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err)) - } - } +// streamResource godoc +// +// @Summary Stream a resource +// @Description *Swagger UI may have problems displaying other file types than images +// @Tags resource +// @Produce octet-stream +// @Param resourceId path int true "Resource ID" +// @Param thumbnail query int false "Thumbnail" +// @Success 200 {object} nil "Requested resource" +// @Failure 400 {object} nil "ID is not a number: %s | Failed to get resource visibility" +// @Failure 401 {object} nil "Resource visibility not match" +// @Failure 404 {object} nil "Resource not found: %d" +// @Failure 500 {object} nil "Failed to find resource by ID: %v | Failed to open the local resource: %s | Failed to read the local resource: %s" +// @Router /o/r/{resourceId} [GET] +func (s *APIV1Service) streamResource(c echo.Context) error { + ctx := c.Request().Context() + resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) + } - ext := filepath.Ext(resource.Filename) - thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) - if err := os.Remove(thumbnailPath); err != nil { - log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err)) - } + resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err) + } - if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ - ID: resourceID, - }); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err) - } - return c.JSON(http.StatusOK, true) + // Protected resource require a logined user + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if resourceVisibility == store.Protected && (!ok || userID <= 0) { + return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err) + } + + resource, err := s.Store.GetResource(ctx, &store.FindResource{ + ID: &resourceID, + GetBlob: true, }) -} + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err) + } + if resource == nil { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) + } -func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) { - f := func(c echo.Context) error { - ctx := c.Request().Context() - resourceID, err := util.ConvertStringToInt32(c.Param("resourceId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) - } + // Private resource require logined user is the creator + if resourceVisibility == store.Private && (!ok || userID != resource.CreatorID) { + return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err) + } - resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID) + blob := resource.Blob + if resource.InternalPath != "" { + resourcePath := resource.InternalPath + src, err := os.Open(resourcePath) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err) - } - - // Protected resource require a logined user - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if resourceVisibility == store.Protected && (!ok || userID <= 0) { - return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err) + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err) } - - resource, err := s.Store.GetResource(ctx, &store.FindResource{ - ID: &resourceID, - GetBlob: true, - }) + defer src.Close() + blob, err = io.ReadAll(src) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err) - } - if resource == nil { - return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID)) - } - - // Private resource require logined user is the creator - if resourceVisibility == store.Private && (!ok || userID != resource.CreatorID) { - return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err) - } - - blob := resource.Blob - if resource.InternalPath != "" { - resourcePath := resource.InternalPath - src, err := os.Open(resourcePath) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err) - } - defer src.Close() - blob, err = io.ReadAll(src) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err) - } - } - - if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") { - ext := filepath.Ext(resource.Filename) - thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) - thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath) - if err != nil { - log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err)) - } else { - blob = thumbnailBlob - } + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err) } + } - c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") - c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'") - resourceType := strings.ToLower(resource.Type) - if strings.HasPrefix(resourceType, "text") { - resourceType = echo.MIMETextPlainCharsetUTF8 - } else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") { - http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob)) - return nil + if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") { + ext := filepath.Ext(resource.Filename) + thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext)) + thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath) + if err != nil { + log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err)) + } else { + blob = thumbnailBlob } - return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob)) } - g.GET("/r/:resourceId", f) - g.GET("/r/:resourceId/*", f) + c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") + c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'") + resourceType := strings.ToLower(resource.Type) + if strings.HasPrefix(resourceType, "text") { + resourceType = echo.MIMETextPlainCharsetUTF8 + } else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") { + http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob)) + return nil + } + return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob)) } func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error { diff --git a/api/v1/rss.go b/api/v1/rss.go index 320d3938..9d8ed0b5 100644 --- a/api/v1/rss.go +++ b/api/v1/rss.go @@ -21,63 +21,84 @@ const maxRSSItemCount = 100 const maxRSSItemTitleLength = 100 func (s *APIV1Service) registerRSSRoutes(g *echo.Group) { - g.GET("/explore/rss.xml", func(c echo.Context) error { - ctx := c.Request().Context() - systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err) - } + g.GET("/explore/rss.xml", s.getRSS) + g.GET("/u/:id/rss.xml", s.getUserRSS) +} - normalStatus := store.Normal - memoFind := store.FindMemo{ - RowStatus: &normalStatus, - VisibilityList: []store.Visibility{store.Public}, - } - memoList, err := s.Store.ListMemos(ctx, &memoFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) - } +// getRSS godoc +// +// @Summary Get RSS +// @Tags rss +// @Produce xml +// @Success 200 {object} nil "RSS" +// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss" +// @Router /explore/rss.xml [GET] +func (s *APIV1Service) getRSS(c echo.Context) error { + ctx := c.Request().Context() + systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err) + } - baseURL := c.Scheme() + "://" + c.Request().Host - rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err) - } - c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8) - return c.String(http.StatusOK, rss) - }) + normalStatus := store.Normal + memoFind := store.FindMemo{ + RowStatus: &normalStatus, + VisibilityList: []store.Visibility{store.Public}, + } + memoList, err := s.Store.ListMemos(ctx, &memoFind) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) + } - g.GET("/u/:id/rss.xml", func(c echo.Context) error { - ctx := c.Request().Context() - id, err := util.ConvertStringToInt32(c.Param("id")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err) - } + baseURL := c.Scheme() + "://" + c.Request().Host + rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err) + } + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8) + return c.String(http.StatusOK, rss) +} - systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err) - } +// getUserRSS godoc +// +// @Summary Get RSS for a user +// @Tags rss +// @Produce xml +// @Param id path int true "User ID" +// @Success 200 {object} nil "RSS" +// @Failure 400 {object} nil "User id is not a number" +// @Failure 500 {object} nil "Failed to get system customized profile | Failed to find memo list | Failed to generate rss" +// @Router /u/{id}/rss.xml [GET] +func (s *APIV1Service) getUserRSS(c echo.Context) error { + ctx := c.Request().Context() + id, err := util.ConvertStringToInt32(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err) + } - normalStatus := store.Normal - memoFind := store.FindMemo{ - CreatorID: &id, - RowStatus: &normalStatus, - VisibilityList: []store.Visibility{store.Public}, - } - memoList, err := s.Store.ListMemos(ctx, &memoFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) - } + systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err) + } - baseURL := c.Scheme() + "://" + c.Request().Host - rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err) - } - c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8) - return c.String(http.StatusOK, rss) - }) + normalStatus := store.Normal + memoFind := store.FindMemo{ + CreatorID: &id, + RowStatus: &normalStatus, + VisibilityList: []store.Visibility{store.Public}, + } + memoList, err := s.Store.ListMemos(ctx, &memoFind) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) + } + + baseURL := c.Scheme() + "://" + c.Request().Host + rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err) + } + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8) + return c.String(http.StatusOK, rss) } func (s *APIV1Service) generateRSSFromMemoList(ctx context.Context, memoList []*store.Memo, baseURL string, profile *CustomizedProfile) (string, error) { diff --git a/api/v1/storage.go b/api/v1/storage.go index e7beba7f..add5a81a 100644 --- a/api/v1/storage.go +++ b/api/v1/storage.go @@ -63,182 +63,238 @@ type UpdateStorageRequest struct { } func (s *APIV1Service) registerStorageRoutes(g *echo.Group) { - g.POST("/storage", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + g.GET("/storage", s.getStorageList) + g.POST("/storage", s.createStorage) + g.DELETE("/storage/:storageId", s.deleteStorage) + g.PATCH("/storage/:storageId", s.updateStorage) +} - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } +// getStorageList godoc +// +// @Summary Get a list of storages +// @Tags storage +// @Produce json +// @Success 200 {object} []store.Storage "List of storages" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to convert storage" +// @Security ApiKeyAuth +// @Router /api/v1/storage [GET] +func (s *APIV1Service) getStorageList(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - create := &CreateStorageRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) - } + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + // We should only show storage list to host user. + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - configString := "" - if create.Type == StorageS3 && create.Config.S3Config != nil { - configBytes, err := json.Marshal(create.Config.S3Config) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) - } - configString = string(configBytes) - } + list, err := s.Store.ListStorages(ctx, &store.FindStorage{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err) + } - storage, err := s.Store.CreateStorage(ctx, &store.Storage{ - Name: create.Name, - Type: create.Type.String(), - Config: configString, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create storage").SetInternal(err) - } + storageList := []*Storage{} + for _, storage := range list { storageMessage, err := ConvertStorageFromStore(storage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err) } - return c.JSON(http.StatusOK, storageMessage) + storageList = append(storageList, storageMessage) + } + return c.JSON(http.StatusOK, storageList) +} + +// createStorage godoc +// +// @Summary Create storage +// @Tags storage +// @Accept json +// @Produce json +// @Param body body CreateStorageRequest true "Request object." +// @Success 200 {object} store.Storage "Created storage" +// @Failure 400 {object} nil "Malformatted post storage request" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to find user | Failed to create storage | Failed to convert storage" +// @Security ApiKeyAuth +// @Router /api/v1/storage [POST] +func (s *APIV1Service) createStorage(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.PATCH("/storage/:storageId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + create := &CreateStorageRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) + configString := "" + if create.Type == StorageS3 && create.Config.S3Config != nil { + configBytes, err := json.Marshal(create.Config.S3Config) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) } + configString = string(configBytes) + } - storageID, err := util.ConvertStringToInt32(c.Param("storageId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err) - } + storage, err := s.Store.CreateStorage(ctx, &store.Storage{ + Name: create.Name, + Type: create.Type.String(), + Config: configString, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create storage").SetInternal(err) + } + storageMessage, err := ConvertStorageFromStore(storage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err) + } + return c.JSON(http.StatusOK, storageMessage) +} - update := &UpdateStorageRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err) - } - storageUpdate := &store.UpdateStorage{ - ID: storageID, - } - if update.Name != nil { - storageUpdate.Name = update.Name - } - if update.Config != nil { - if update.Type == StorageS3 { - configBytes, err := json.Marshal(update.Config.S3Config) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) - } - configString := string(configBytes) - storageUpdate.Config = &configString - } - } +// deleteStorage godoc +// +// @Summary Delete a storage +// @Tags storage +// @Produce json +// @Param storageId path int true "Storage ID" +// @Success 200 {boolean} true "Storage deleted" +// @Failure 400 {object} nil "ID is not a number: %s | Storage service %d is using" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to find storage | Failed to unmarshal storage service id | Failed to delete storage" +// @Security ApiKeyAuth +// @Router /api/v1/storage/{storageId} [DELETE] +// +// NOTES: +// - error message "Storage service %d is using" probably should be "Storage service %d is in use". +func (s *APIV1Service) deleteStorage(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - storage, err := s.Store.UpdateStorage(ctx, storageUpdate) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err) - } - storageMessage, err := ConvertStorageFromStore(storage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err) - } - return c.JSON(http.StatusOK, storageMessage) + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - g.GET("/storage", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + storageID, err := util.ConvertStringToInt32(c.Param("storageId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err) + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) + systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err) + } + if systemSetting != nil { + storageServiceID := DatabaseStorage + err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - // We should only show storage list to host user. - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err) } - - list, err := s.Store.ListStorages(ctx, &store.FindStorage{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err) + if storageServiceID == storageID { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID)) } + } - storageList := []*Storage{} - for _, storage := range list { - storageMessage, err := ConvertStorageFromStore(storage) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err) - } - storageList = append(storageList, storageMessage) - } - return c.JSON(http.StatusOK, storageList) - }) + if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - g.DELETE("/storage/:storageId", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } +// updateStorage godoc +// +// @Summary Update a storage +// @Tags storage +// @Produce json +// @Param storageId path int true "Storage ID" +// @Param patch body UpdateStorageRequest true "Patch request" +// @Success 200 {object} store.Storage "Updated resource" +// @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch storage request | Malformatted post storage request" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to patch storage | Failed to convert storage" +// @Security ApiKeyAuth +// @Router /api/v1/storage/{storageId} [PATCH] +func (s *APIV1Service) updateStorage(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } - storageID, err := util.ConvertStringToInt32(c.Param("storageId")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err) - } + storageID, err := util.ConvertStringToInt32(c.Param("storageId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err) + } - systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err) - } - if systemSetting != nil { - storageServiceID := DatabaseStorage - err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID) + update := &UpdateStorageRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err) + } + storageUpdate := &store.UpdateStorage{ + ID: storageID, + } + if update.Name != nil { + storageUpdate.Name = update.Name + } + if update.Config != nil { + if update.Type == StorageS3 { + configBytes, err := json.Marshal(update.Config.S3Config) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err) - } - if storageServiceID == storageID { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID)) + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err) } + configString := string(configBytes) + storageUpdate.Config = &configString } + } - if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err) - } - return c.JSON(http.StatusOK, true) - }) + storage, err := s.Store.UpdateStorage(ctx, storageUpdate) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err) + } + storageMessage, err := ConvertStorageFromStore(storage) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err) + } + return c.JSON(http.StatusOK, storageMessage) } func ConvertStorageFromStore(storage *store.Storage) (*Storage, error) { diff --git a/api/v1/system.go b/api/v1/system.go index 07e89403..68a36e46 100644 --- a/api/v1/system.go +++ b/api/v1/system.go @@ -43,118 +43,148 @@ type SystemStatus struct { } func (s *APIV1Service) registerSystemRoutes(g *echo.Group) { - g.GET("/ping", func(c echo.Context) error { - return c.JSON(http.StatusOK, s.Profile) - }) - - g.GET("/status", func(c echo.Context) error { - ctx := c.Request().Context() - - systemStatus := SystemStatus{ - Profile: *s.Profile, - DBSize: 0, - AllowSignUp: false, - DisablePasswordLogin: false, - DisablePublicMemos: false, - MaxUploadSizeMiB: 32, - AutoBackupInterval: 0, - AdditionalStyle: "", - AdditionalScript: "", - CustomizedProfile: CustomizedProfile{ - Name: "memos", - LogoURL: "", - Description: "", - Locale: "en", - Appearance: "system", - ExternalURL: "", - }, - StorageServiceID: DatabaseStorage, - LocalStoragePath: "assets/{timestamp}_{filename}", - MemoDisplayWithUpdatedTs: false, - } - - hostUserType := store.RoleHost - hostUser, err := s.Store.GetUser(ctx, &store.FindUser{ - Role: &hostUserType, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err) - } - if hostUser != nil { - systemStatus.Host = &User{ID: hostUser.ID} - } + g.GET("/ping", s.ping) + g.GET("/status", s.status) + g.POST("/system/vacuum", s.vacuum) +} - systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err) - } - for _, systemSetting := range systemSettingList { - if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() { - continue - } +// ping godoc +// +// @Summary Ping the system +// @Tags system +// @Produce json +// @Success 200 {object} profile.Profile "System profile" +// @Router /api/v1/ping [GET] +func (s *APIV1Service) ping(c echo.Context) error { + return c.JSON(http.StatusOK, s.Profile) +} - var baseValue any - err := json.Unmarshal([]byte(systemSetting.Value), &baseValue) - if err != nil { - log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name)) - continue - } +// status godoc +// +// @Summary Get system status +// @Tags system +// @Produce json +// @Success 200 {object} SystemStatus "System status" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find host user | Failed to find system setting list | Failed to unmarshal system setting customized profile value" +// @Router /api/v1/status [GET] +func (s *APIV1Service) status(c echo.Context) error { + ctx := c.Request().Context() - switch systemSetting.Name { - case SystemSettingAllowSignUpName.String(): - systemStatus.AllowSignUp = baseValue.(bool) - case SystemSettingDisablePasswordLoginName.String(): - systemStatus.DisablePasswordLogin = baseValue.(bool) - case SystemSettingDisablePublicMemosName.String(): - systemStatus.DisablePublicMemos = baseValue.(bool) - case SystemSettingMaxUploadSizeMiBName.String(): - systemStatus.MaxUploadSizeMiB = int(baseValue.(float64)) - case SystemSettingAutoBackupIntervalName.String(): - systemStatus.AutoBackupInterval = int(baseValue.(float64)) - case SystemSettingAdditionalStyleName.String(): - systemStatus.AdditionalStyle = baseValue.(string) - case SystemSettingAdditionalScriptName.String(): - systemStatus.AdditionalScript = baseValue.(string) - case SystemSettingCustomizedProfileName.String(): - customizedProfile := CustomizedProfile{} - if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err) - } - systemStatus.CustomizedProfile = customizedProfile - case SystemSettingStorageServiceIDName.String(): - systemStatus.StorageServiceID = int32(baseValue.(float64)) - case SystemSettingLocalStoragePathName.String(): - systemStatus.LocalStoragePath = baseValue.(string) - case SystemSettingMemoDisplayWithUpdatedTsName.String(): - systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool) - default: - log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name)) - } - } + systemStatus := SystemStatus{ + Profile: *s.Profile, + DBSize: 0, + AllowSignUp: false, + DisablePasswordLogin: false, + DisablePublicMemos: false, + MaxUploadSizeMiB: 32, + AutoBackupInterval: 0, + AdditionalStyle: "", + AdditionalScript: "", + CustomizedProfile: CustomizedProfile{ + Name: "memos", + LogoURL: "", + Description: "", + Locale: "en", + Appearance: "system", + ExternalURL: "", + }, + StorageServiceID: DatabaseStorage, + LocalStoragePath: "assets/{timestamp}_{filename}", + MemoDisplayWithUpdatedTs: false, + } - return c.JSON(http.StatusOK, systemStatus) + hostUserType := store.RoleHost + hostUser, err := s.Store.GetUser(ctx, &store.FindUser{ + Role: &hostUserType, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err) + } + if hostUser != nil { + systemStatus.Host = &User{ID: hostUser.ID} + } - g.POST("/system/vacuum", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err) + } + for _, systemSetting := range systemSettingList { + if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() { + continue } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) + var baseValue any + err := json.Unmarshal([]byte(systemSetting.Value), &baseValue) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name)) + continue } - if err := s.Store.Vacuum(ctx); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err) + switch systemSetting.Name { + case SystemSettingAllowSignUpName.String(): + systemStatus.AllowSignUp = baseValue.(bool) + case SystemSettingDisablePasswordLoginName.String(): + systemStatus.DisablePasswordLogin = baseValue.(bool) + case SystemSettingDisablePublicMemosName.String(): + systemStatus.DisablePublicMemos = baseValue.(bool) + case SystemSettingMaxUploadSizeMiBName.String(): + systemStatus.MaxUploadSizeMiB = int(baseValue.(float64)) + case SystemSettingAutoBackupIntervalName.String(): + systemStatus.AutoBackupInterval = int(baseValue.(float64)) + case SystemSettingAdditionalStyleName.String(): + systemStatus.AdditionalStyle = baseValue.(string) + case SystemSettingAdditionalScriptName.String(): + systemStatus.AdditionalScript = baseValue.(string) + case SystemSettingCustomizedProfileName.String(): + customizedProfile := CustomizedProfile{} + if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err) + } + systemStatus.CustomizedProfile = customizedProfile + case SystemSettingStorageServiceIDName.String(): + systemStatus.StorageServiceID = int32(baseValue.(float64)) + case SystemSettingLocalStoragePathName.String(): + systemStatus.LocalStoragePath = baseValue.(string) + case SystemSettingMemoDisplayWithUpdatedTsName.String(): + systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool) + default: + log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name)) } - return c.JSON(http.StatusOK, true) + } + + return c.JSON(http.StatusOK, systemStatus) +} + +// vacuum godoc +// +// @Summary Vacuum the database +// @Tags system +// @Produce json +// @Success 200 {boolean} true "Database vacuumed" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to vacuum database" +// @Security ApiKeyAuth +// @Router /api/v1/system/vacuum [POST] +func (s *APIV1Service) vacuum(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } + + if err := s.Store.Vacuum(ctx); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err) + } + return c.JSON(http.StatusOK, true) } diff --git a/api/v1/system_setting.go b/api/v1/system_setting.go index 41fd824b..3e1417b6 100644 --- a/api/v1/system_setting.go +++ b/api/v1/system_setting.go @@ -43,6 +43,7 @@ const ( // SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds. SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval" ) +const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"` // CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item. type CustomizedProfile struct { @@ -77,7 +78,113 @@ type UpsertSystemSettingRequest struct { Description string `json:"description"` } -const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"` +func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) { + g.GET("/system/setting", s.getSystemSettingList) + g.POST("/system/setting", s.createSystemSetting) +} + +// getSystemSettingList godoc +// +// @Summary Get a list of system settings +// @Tags system-setting +// @Produce json +// @Success 200 {object} []SystemSetting "System setting list" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 500 {object} nil "Failed to find user | Failed to find system setting list" +// @Security ApiKeyAuth +// @Router /api/v1/system/setting [GET] +func (s *APIV1Service) getSystemSettingList(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } + + list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err) + } + + systemSettingList := make([]*SystemSetting, 0, len(list)) + for _, systemSetting := range list { + systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting)) + } + return c.JSON(http.StatusOK, systemSettingList) +} + +// createSystemSetting godoc +// +// @Summary Create system setting +// @Tags system-setting +// @Accept json +// @Produce json +// @Param body body UpsertSystemSettingRequest true "Request object." +// @Success 200 {object} store.SystemSetting "Created system setting" +// @Failure 400 {object} nil "Malformatted post system setting request | invalid system setting" +// @Failure 401 {object} nil "Missing user in session | Unauthorized" +// @Failure 403 {object} nil "Cannot disable passwords if no SSO identity provider is configured." +// @Failure 500 {object} nil "Failed to find user | Failed to upsert system setting" +// @Security ApiKeyAuth +// @Router /api/v1/system/setting [POST] +func (s *APIV1Service) createSystemSetting(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil || user.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") + } + + systemSettingUpsert := &UpsertSystemSettingRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err) + } + if err := systemSettingUpsert.Validate(); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err) + } + if systemSettingUpsert.Name == SystemSettingDisablePasswordLoginName { + var disablePasswordLogin bool + if err := json.Unmarshal([]byte(systemSettingUpsert.Value), &disablePasswordLogin); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err) + } + + identityProviderList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err) + } + if disablePasswordLogin && len(identityProviderList) == 0 { + return echo.NewHTTPError(http.StatusForbidden, "Cannot disable passwords if no SSO identity provider is configured.") + } + } + + systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{ + Name: systemSettingUpsert.Name.String(), + Value: systemSettingUpsert.Value, + Description: systemSettingUpsert.Description, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err) + } + return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting)) +} func (upsert UpsertSystemSettingRequest) Validate() error { switch settingName := upsert.Name; settingName { @@ -172,87 +279,6 @@ func (upsert UpsertSystemSettingRequest) Validate() error { return nil } -func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) { - g.POST("/system/setting", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - systemSettingUpsert := &UpsertSystemSettingRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err) - } - if err := systemSettingUpsert.Validate(); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err) - } - if systemSettingUpsert.Name == SystemSettingDisablePasswordLoginName { - var disablePasswordLogin bool - if err := json.Unmarshal([]byte(systemSettingUpsert.Value), &disablePasswordLogin); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err) - } - - identityProviderList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err) - } - if disablePasswordLogin && len(identityProviderList) == 0 { - return echo.NewHTTPError(http.StatusForbidden, "Cannot disable passwords if no SSO identity provider is configured.") - } - } - - systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{ - Name: systemSettingUpsert.Name.String(), - Value: systemSettingUpsert.Value, - Description: systemSettingUpsert.Description, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err) - } - return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting)) - }) - - g.GET("/system/setting", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil || user.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err) - } - - systemSettingList := make([]*SystemSetting, 0, len(list)) - for _, systemSetting := range list { - systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting)) - } - return c.JSON(http.StatusOK, systemSettingList) - }) -} - func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting { return &SystemSetting{ Name: SystemSettingName(systemSetting.Name), diff --git a/api/v1/tag.go b/api/v1/tag.go index 12d95f57..10269da9 100644 --- a/api/v1/tag.go +++ b/api/v1/tag.go @@ -28,125 +28,176 @@ type DeleteTagRequest struct { } func (s *APIV1Service) registerTagRoutes(g *echo.Group) { - g.POST("/tag", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } + g.GET("/tag", s.getTagList) + g.POST("/tag", s.createTag) + g.POST("/tag/delete", s.deleteTag) + g.GET("/tag/suggestion", s.getTagSuggestion) +} - tagUpsert := &UpsertTagRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err) - } - if tagUpsert.Name == "" { - return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty") - } +// getTagList godoc +// +// @Summary Get a list of tags +// @Tags tag +// @Produce json +// @Success 200 {object} []string "Tag list" +// @Failure 400 {object} nil "Missing user id to find tag" +// @Failure 500 {object} nil "Failed to find tag list" +// @Security ApiKeyAuth +// @Router /api/v1/tag [GET] +func (s *APIV1Service) getTagList(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag") + } - tag, err := s.Store.UpsertTag(ctx, &store.Tag{ - Name: tagUpsert.Name, - CreatorID: userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err) - } - tagMessage := convertTagFromStore(tag) - if err := s.createTagCreateActivity(c, tagMessage); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) - } - return c.JSON(http.StatusOK, tagMessage.Name) + list, err := s.Store.ListTags(ctx, &store.FindTag{ + CreatorID: userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err) + } - g.GET("/tag", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag") - } + tagNameList := []string{} + for _, tag := range list { + tagNameList = append(tagNameList, tag.Name) + } + return c.JSON(http.StatusOK, tagNameList) +} - list, err := s.Store.ListTags(ctx, &store.FindTag{ - CreatorID: userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err) - } +// createTag godoc +// +// @Summary Create a tag +// @Tags tag +// @Accept json +// @Produce json +// @Param body body UpsertTagRequest true "Request object." +// @Success 200 {object} string "Created tag name" +// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to upsert tag | Failed to create activity" +// @Security ApiKeyAuth +// @Router /api/v1/tag [POST] +func (s *APIV1Service) createTag(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - tagNameList := []string{} - for _, tag := range list { - tagNameList = append(tagNameList, tag.Name) - } - return c.JSON(http.StatusOK, tagNameList) - }) + tagUpsert := &UpsertTagRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err) + } + if tagUpsert.Name == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty") + } - g.GET("/tag/suggestion", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusBadRequest, "Missing user session") - } - normalRowStatus := store.Normal - memoFind := &store.FindMemo{ - CreatorID: &userID, - ContentSearch: []string{"#"}, - RowStatus: &normalRowStatus, - } + tag, err := s.Store.UpsertTag(ctx, &store.Tag{ + Name: tagUpsert.Name, + CreatorID: userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err) + } + tagMessage := convertTagFromStore(tag) + if err := s.createTagCreateActivity(c, tagMessage); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + return c.JSON(http.StatusOK, tagMessage.Name) +} - memoMessageList, err := s.Store.ListMemos(ctx, memoFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) - } +// deleteTag godoc +// +// @Summary Delete a tag +// @Tags tag +// @Accept json +// @Produce json +// @Param body body DeleteTagRequest true "Request object." +// @Success 200 {boolean} true "Tag deleted" +// @Failure 400 {object} nil "Malformatted post tag request | Tag name shouldn't be empty" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 500 {object} nil "Failed to delete tag name: %v" +// @Security ApiKeyAuth +// @Router /api/v1/tag/delete [POST] +func (s *APIV1Service) deleteTag(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } - list, err := s.Store.ListTags(ctx, &store.FindTag{ - CreatorID: userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err) - } - tagNameList := []string{} - for _, tag := range list { - tagNameList = append(tagNameList, tag.Name) - } + tagDelete := &DeleteTagRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err) + } + if tagDelete.Name == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty") + } - tagMapSet := make(map[string]bool) - for _, memo := range memoMessageList { - for _, tag := range findTagListFromMemoContent(memo.Content) { - if !slices.Contains(tagNameList, tag) { - tagMapSet[tag] = true - } - } - } - tagList := []string{} - for tag := range tagMapSet { - tagList = append(tagList, tag) - } - sort.Strings(tagList) - return c.JSON(http.StatusOK, tagList) + err := s.Store.DeleteTag(ctx, &store.DeleteTag{ + Name: tagDelete.Name, + CreatorID: userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} - g.POST("/tag/delete", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } +// getTagSuggestion godoc +// +// @Summary Get a list of tags suggested from other memos contents +// @Tags tag +// @Produce json +// @Success 200 {object} []string "Tag list" +// @Failure 400 {object} nil "Missing user session" +// @Failure 500 {object} nil "Failed to find memo list | Failed to find tag list" +// @Security ApiKeyAuth +// @Router /api/v1/tag/suggestion [GET] +func (s *APIV1Service) getTagSuggestion(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusBadRequest, "Missing user session") + } + normalRowStatus := store.Normal + memoFind := &store.FindMemo{ + CreatorID: &userID, + ContentSearch: []string{"#"}, + RowStatus: &normalRowStatus, + } - tagDelete := &DeleteTagRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err) - } - if tagDelete.Name == "" { - return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty") - } + memoMessageList, err := s.Store.ListMemos(ctx, memoFind) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) + } - err := s.Store.DeleteTag(ctx, &store.DeleteTag{ - Name: tagDelete.Name, - CreatorID: userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err) - } - return c.JSON(http.StatusOK, true) + list, err := s.Store.ListTags(ctx, &store.FindTag{ + CreatorID: userID, }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err) + } + tagNameList := []string{} + for _, tag := range list { + tagNameList = append(tagNameList, tag.Name) + } + + tagMapSet := make(map[string]bool) + for _, memo := range memoMessageList { + for _, tag := range findTagListFromMemoContent(memo.Content) { + if !slices.Contains(tagNameList, tag) { + tagMapSet[tag] = true + } + } + } + tagList := []string{} + for tag := range tagMapSet { + tagList = append(tagList, tag) + } + sort.Strings(tagList) + return c.JSON(http.StatusOK, tagList) } func (s *APIV1Service) createTagCreateActivity(c echo.Context, tag *Tag) error { diff --git a/api/v1/user.go b/api/v1/user.go index 51fc863d..e813d2ba 100644 --- a/api/v1/user.go +++ b/api/v1/user.go @@ -57,6 +57,363 @@ type CreateUserRequest struct { Password string `json:"password"` } +type UpdateUserRequest struct { + RowStatus *RowStatus `json:"rowStatus"` + Username *string `json:"username"` + Email *string `json:"email"` + Nickname *string `json:"nickname"` + Password *string `json:"password"` + ResetOpenID *bool `json:"resetOpenId"` + AvatarURL *string `json:"avatarUrl"` +} + +func (s *APIV1Service) registerUserRoutes(g *echo.Group) { + g.GET("/user", s.getUserList) + g.POST("/user", s.createUser) + g.GET("/user/me", s.getCurrentUser) + // NOTE: This should be moved to /api/v2/user/:username + g.GET("/user/name/:username", s.getUserByUsername) + g.GET("/user/:id", s.getUserByID) + g.DELETE("/user/:id", s.deleteUser) + g.PATCH("/user/:id", s.updateUser) +} + +// getUserList godoc +// +// @Summary Get a list of users +// @Tags user +// @Produce json +// @Success 200 {object} []store.User "User list" +// @Failure 500 {object} nil "Failed to fetch user list" +// @Router /api/v1/user [GET] +func (s *APIV1Service) getUserList(c echo.Context) error { + ctx := c.Request().Context() + list, err := s.Store.ListUsers(ctx, &store.FindUser{}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err) + } + + userMessageList := make([]*User, 0, len(list)) + for _, user := range list { + userMessage := convertUserFromStore(user) + // data desensitize + userMessage.OpenID = "" + userMessage.Email = "" + userMessageList = append(userMessageList, userMessage) + } + return c.JSON(http.StatusOK, userMessageList) +} + +// createUser godoc +// +// @Summary Create a user +// @Tags user +// @Accept json +// @Produce json +// @Param body body CreateUserRequest true "Request object" +// @Success 200 {object} store.User "Created user" +// @Failure 400 {object} nil "Malformatted post user request | Invalid user create format" +// @Failure 401 {object} nil "Missing auth session | Unauthorized to create user" +// @Failure 403 {object} nil "Could not create host user" +// @Failure 500 {object} nil "Failed to find user by id | Failed to generate password hash | Failed to create user | Failed to create activity" +// @Router /api/v1/user [POST] +func (s *APIV1Service) createUser(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + if currentUser.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user") + } + + userCreate := &CreateUserRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err) + } + if err := userCreate.Validate(); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err) + } + // Disallow host user to be created. + if userCreate.Role == RoleHost { + return echo.NewHTTPError(http.StatusForbidden, "Could not create host user") + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) + } + + user, err := s.Store.CreateUser(ctx, &store.User{ + Username: userCreate.Username, + Role: store.Role(userCreate.Role), + Email: userCreate.Email, + Nickname: userCreate.Nickname, + PasswordHash: string(passwordHash), + OpenID: util.GenUUID(), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) + } + + userMessage := convertUserFromStore(user) + if err := s.createUserCreateActivity(c, userMessage); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + return c.JSON(http.StatusOK, userMessage) +} + +// getCurrentUser godoc +// +// @Summary Get current user +// @Tags user +// @Produce json +// @Success 200 {object} store.User "Current user" +// @Failure 401 {object} nil "Missing auth session" +// @Failure 500 {object} nil "Failed to find user | Failed to find userSettingList" +// @Security ApiKeyAuth +// @Router /api/v1/user/me [GET] +func (s *APIV1Service) getCurrentUser(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + + list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{ + UserID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err) + } + userSettingList := []*UserSetting{} + for _, userSetting := range list { + userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting)) + } + userMessage := convertUserFromStore(user) + userMessage.UserSettingList = userSettingList + return c.JSON(http.StatusOK, userMessage) +} + +// getUserByUsername godoc +// +// @Summary Get user by username +// @Tags user +// @Produce json +// @Param username path string true "Username" +// @Success 200 {object} store.User "Requested user" +// @Failure 404 {object} nil "User not found" +// @Failure 500 {object} nil "Failed to find user" +// @Router /api/v1/user/name/{username} [GET] +func (s *APIV1Service) getUserByUsername(c echo.Context) error { + ctx := c.Request().Context() + username := c.Param("username") + user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + userMessage := convertUserFromStore(user) + // data desensitize + userMessage.OpenID = "" + userMessage.Email = "" + return c.JSON(http.StatusOK, userMessage) +} + +// getUserByID godoc +// +// @Summary Get user by id +// @Tags user +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} store.User "Requested user" +// @Failure 400 {object} nil "Malformatted user id" +// @Failure 404 {object} nil "User not found" +// @Failure 500 {object} nil "Failed to find user" +// @Router /api/v1/user/{id} [GET] +func (s *APIV1Service) getUserByID(c echo.Context) error { + ctx := c.Request().Context() + id, err := util.ConvertStringToInt32(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err) + } + + user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + userMessage := convertUserFromStore(user) + // data desensitize + userMessage.OpenID = "" + userMessage.Email = "" + return c.JSON(http.StatusOK, userMessage) +} + +// deleteUser godoc +// +// @Summary Delete a user +// @Tags user +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {boolean} true "User deleted" +// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 403 {object} nil "Unauthorized to delete user" +// @Failure 500 {object} nil "Failed to find user | Failed to delete user" +// @Router /api/v1/user/{id} [DELETE] +func (s *APIV1Service) deleteUser(c echo.Context) error { + ctx := c.Request().Context() + currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: ¤tUserID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err) + } else if currentUser.Role != store.RoleHost { + return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete user").SetInternal(err) + } + + userID, err := util.ConvertStringToInt32(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err) + } + + userDelete := &store.DeleteUser{ + ID: userID, + } + if err := s.Store.DeleteUser(ctx, userDelete); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err) + } + return c.JSON(http.StatusOK, true) +} + +// updateUser godoc +// +// @Summary Update a user +// @Tags user +// @Produce json +// @Param id path string true "User ID" +// @Param patch body UpdateUserRequest true "Patch request" +// @Success 200 {object} store.User "Updated user" +// @Failure 400 {object} nil "ID is not a number: %s | Current session user not found with ID: %d | Malformatted patch user request | Invalid update user request" +// @Failure 401 {object} nil "Missing user in session" +// @Failure 403 {object} nil "Unauthorized to update user" +// @Failure 500 {object} nil "Failed to find user | Failed to generate password hash | Failed to patch user | Failed to find userSettingList" +// @Router /api/v1/user/{id} [PATCH] +func (s *APIV1Service) updateUser(c echo.Context) error { + ctx := c.Request().Context() + userID, err := util.ConvertStringToInt32(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err) + } + + currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") + } + currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: ¤tUserID}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err) + } else if currentUser.Role != store.RoleHost && currentUserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to update user").SetInternal(err) + } + + request := &UpdateUserRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err) + } + if err := request.Validate(); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid update user request").SetInternal(err) + } + + currentTs := time.Now().Unix() + userUpdate := &store.UpdateUser{ + ID: userID, + UpdatedTs: ¤tTs, + } + if request.RowStatus != nil { + rowStatus := store.RowStatus(request.RowStatus.String()) + userUpdate.RowStatus = &rowStatus + } + if request.Username != nil { + userUpdate.Username = request.Username + } + if request.Email != nil { + userUpdate.Email = request.Email + } + if request.Nickname != nil { + userUpdate.Nickname = request.Nickname + } + if request.Password != nil { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) + } + + passwordHashStr := string(passwordHash) + userUpdate.PasswordHash = &passwordHashStr + } + if request.ResetOpenID != nil && *request.ResetOpenID { + openID := util.GenUUID() + userUpdate.OpenID = &openID + } + if request.AvatarURL != nil { + userUpdate.AvatarURL = request.AvatarURL + } + + user, err := s.Store.UpdateUser(ctx, userUpdate) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err) + } + + list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{ + UserID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err) + } + userSettingList := []*UserSetting{} + for _, userSetting := range list { + userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting)) + } + userMessage := convertUserFromStore(user) + userMessage.UserSettingList = userSettingList + return c.JSON(http.StatusOK, userMessage) +} + func (create CreateUserRequest) Validate() error { if len(create.Username) < 3 { return fmt.Errorf("username is too short, minimum length is 3") @@ -85,16 +442,6 @@ func (create CreateUserRequest) Validate() error { return nil } -type UpdateUserRequest struct { - RowStatus *RowStatus `json:"rowStatus"` - Username *string `json:"username"` - Email *string `json:"email"` - Nickname *string `json:"nickname"` - Password *string `json:"password"` - ResetOpenID *bool `json:"resetOpenId"` - AvatarURL *string `json:"avatarUrl"` -} - func (update UpdateUserRequest) Validate() error { if update.Username != nil && len(*update.Username) < 3 { return fmt.Errorf("username is too short, minimum length is 3") @@ -128,275 +475,6 @@ func (update UpdateUserRequest) Validate() error { return nil } -func (s *APIV1Service) registerUserRoutes(g *echo.Group) { - // POST /user - Create a new user. - g.POST("/user", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") - } - currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err) - } - if currentUser == nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") - } - if currentUser.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user") - } - - userCreate := &CreateUserRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err) - } - if err := userCreate.Validate(); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err) - } - // Disallow host user to be created. - if userCreate.Role == RoleHost { - return echo.NewHTTPError(http.StatusForbidden, "Could not create host user") - } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) - } - - user, err := s.Store.CreateUser(ctx, &store.User{ - Username: userCreate.Username, - Role: store.Role(userCreate.Role), - Email: userCreate.Email, - Nickname: userCreate.Nickname, - PasswordHash: string(passwordHash), - OpenID: util.GenUUID(), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) - } - - userMessage := convertUserFromStore(user) - if err := s.createUserCreateActivity(c, userMessage); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) - } - return c.JSON(http.StatusOK, userMessage) - }) - - // GET /user - List all users. - g.GET("/user", func(c echo.Context) error { - ctx := c.Request().Context() - list, err := s.Store.ListUsers(ctx, &store.FindUser{}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err) - } - - userMessageList := make([]*User, 0, len(list)) - for _, user := range list { - userMessage := convertUserFromStore(user) - // data desensitize - userMessage.OpenID = "" - userMessage.Email = "" - userMessageList = append(userMessageList, userMessage) - } - return c.JSON(http.StatusOK, userMessageList) - }) - - // GET /user/me - Get current user. - g.GET("/user/me", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") - } - - user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") - } - - list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{ - UserID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err) - } - userSettingList := []*UserSetting{} - for _, userSetting := range list { - userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting)) - } - userMessage := convertUserFromStore(user) - userMessage.UserSettingList = userSettingList - return c.JSON(http.StatusOK, userMessage) - }) - - // GET /user/:id - Get user by id. - g.GET("/user/:id", func(c echo.Context) error { - ctx := c.Request().Context() - id, err := util.ConvertStringToInt32(c.Param("id")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err) - } - - user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil { - return echo.NewHTTPError(http.StatusNotFound, "User not found") - } - - userMessage := convertUserFromStore(user) - // data desensitize - userMessage.OpenID = "" - userMessage.Email = "" - return c.JSON(http.StatusOK, userMessage) - }) - - // GET /user/name/:username - Get user by username. - // NOTE: This should be moved to /api/v2/user/:username - g.GET("/user/name/:username", func(c echo.Context) error { - ctx := c.Request().Context() - username := c.Param("username") - user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if user == nil { - return echo.NewHTTPError(http.StatusNotFound, "User not found") - } - - userMessage := convertUserFromStore(user) - // data desensitize - userMessage.OpenID = "" - userMessage.Email = "" - return c.JSON(http.StatusOK, userMessage) - }) - - // PUT /user/:id - Update user by id. - g.PATCH("/user/:id", func(c echo.Context) error { - ctx := c.Request().Context() - userID, err := util.ConvertStringToInt32(c.Param("id")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err) - } - - currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: ¤tUserID}) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if currentUser == nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err) - } else if currentUser.Role != store.RoleHost && currentUserID != userID { - return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to update user").SetInternal(err) - } - - request := &UpdateUserRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err) - } - if err := request.Validate(); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid update user request").SetInternal(err) - } - - currentTs := time.Now().Unix() - userUpdate := &store.UpdateUser{ - ID: userID, - UpdatedTs: ¤tTs, - } - if request.RowStatus != nil { - rowStatus := store.RowStatus(request.RowStatus.String()) - userUpdate.RowStatus = &rowStatus - } - if request.Username != nil { - userUpdate.Username = request.Username - } - if request.Email != nil { - userUpdate.Email = request.Email - } - if request.Nickname != nil { - userUpdate.Nickname = request.Nickname - } - if request.Password != nil { - passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) - } - - passwordHashStr := string(passwordHash) - userUpdate.PasswordHash = &passwordHashStr - } - if request.ResetOpenID != nil && *request.ResetOpenID { - openID := util.GenUUID() - userUpdate.OpenID = &openID - } - if request.AvatarURL != nil { - userUpdate.AvatarURL = request.AvatarURL - } - - user, err := s.Store.UpdateUser(ctx, userUpdate) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err) - } - - list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{ - UserID: &userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err) - } - userSettingList := []*UserSetting{} - for _, userSetting := range list { - userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting)) - } - userMessage := convertUserFromStore(user) - userMessage.UserSettingList = userSettingList - return c.JSON(http.StatusOK, userMessage) - }) - - // DELETE /user/:id - Delete user by id. - g.DELETE("/user/:id", func(c echo.Context) error { - ctx := c.Request().Context() - currentUserID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") - } - currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: ¤tUserID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) - } - if currentUser == nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err) - } else if currentUser.Role != store.RoleHost { - return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete user").SetInternal(err) - } - - userID, err := util.ConvertStringToInt32(c.Param("id")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err) - } - - userDelete := &store.DeleteUser{ - ID: userID, - } - if err := s.Store.DeleteUser(ctx, userDelete); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err) - } - return c.JSON(http.StatusOK, true) - }) -} - func (s *APIV1Service) createUserCreateActivity(c echo.Context, user *User) error { ctx := c.Request().Context() payload := ActivityUserCreatePayload{ diff --git a/api/v1/user_setting.go b/api/v1/user_setting.go index 4ef883f3..e7755de8 100644 --- a/api/v1/user_setting.go +++ b/api/v1/user_setting.go @@ -78,6 +78,52 @@ type UpsertUserSettingRequest struct { Value string `json:"value"` } +func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) { + g.POST("/user/setting", s.createUserSetting) +} + +// createUserSetting godoc +// +// @Summary Create user setting +// @Tags user-setting +// @Accept json +// @Produce json +// @Param body body UpsertUserSettingRequest true "Request object." +// @Success 200 {object} store.UserSetting "Created user setting" +// @Failure 400 {object} nil "Malformatted post user setting upsert request | Invalid user setting format" +// @Failure 401 {object} nil "Missing auth session" +// @Failure 500 {object} nil "Failed to upsert user setting" +// @Security ApiKeyAuth +// @Router /api/v1/user/setting [POST] +func (s *APIV1Service) createUserSetting(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(auth.UserIDContextKey).(int32) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + + userSettingUpsert := &UpsertUserSettingRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err) + } + if err := userSettingUpsert.Validate(); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err) + } + + userSettingUpsert.UserID = userID + userSetting, err := s.Store.UpsertUserSetting(ctx, &store.UserSetting{ + UserID: userID, + Key: userSettingUpsert.Key.String(), + Value: userSettingUpsert.Value, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err) + } + + userSettingMessage := convertUserSettingFromStore(userSetting) + return c.JSON(http.StatusOK, userSettingMessage) +} + func (upsert UpsertUserSettingRequest) Validate() error { if upsert.Key == UserSettingLocaleKey { localeValue := "en" @@ -119,37 +165,6 @@ func (upsert UpsertUserSettingRequest) Validate() error { return nil } -func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) { - g.POST("/user/setting", func(c echo.Context) error { - ctx := c.Request().Context() - userID, ok := c.Get(auth.UserIDContextKey).(int32) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") - } - - userSettingUpsert := &UpsertUserSettingRequest{} - if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err) - } - if err := userSettingUpsert.Validate(); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err) - } - - userSettingUpsert.UserID = userID - userSetting, err := s.Store.UpsertUserSetting(ctx, &store.UserSetting{ - UserID: userID, - Key: userSettingUpsert.Key.String(), - Value: userSettingUpsert.Value, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err) - } - - userSettingMessage := convertUserSettingFromStore(userSetting) - return c.JSON(http.StatusOK, userSettingMessage) - }) -} - func convertUserSettingFromStore(userSetting *store.UserSetting) *UserSetting { return &UserSetting{ UserID: userSetting.UserID, diff --git a/docs/api/v1.md b/docs/api/v1.md new file mode 100644 index 00000000..ccfa099e --- /dev/null +++ b/docs/api/v1.md @@ -0,0 +1,1848 @@ +# memos API +A privacy-first, lightweight note-taking service. + +## Version: 1.0 + +**Contact information:** +API Support + + +**License:** [MIT License](https://github.com/usememos/memos/blob/main/LICENSE) + +[Find out more about Memos](https://usememos.com/) + +### Security +**ApiKeyAuth** + +| apiKey | *API Key* | +| ------ | --------- | +| Description | Insert your Open ID API Key here. | +| In | query | +| Name | openId | + +--- +### /api/v1/auth/signin + +#### POST +##### Summary + +Sign-in to memos. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Sign-in object | Yes | [v1.SignIn](#v1signin) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User information | [store.User](#storeuser) | +| 400 | Malformatted signin request | | +| 401 | Password login is deactivated \| Incorrect login credentials, please try again | | +| 403 | User has been archived with username %s | | +| 500 | Failed to find system setting \| Failed to unmarshal system setting \| Incorrect login credentials, please try again \| Failed to generate tokens \| Failed to create activity | | + +### /api/v1/auth/signin/sso + +#### POST +##### Summary + +Sign-in to memos using SSO. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | SSO sign-in object | Yes | [v1.SSOSignIn](#v1ssosignin) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User information | [store.User](#storeuser) | +| 400 | Malformatted signin request | | +| 401 | Access denied, identifier does not match the filter. | | +| 403 | User has been archived with username {username} | | +| 404 | Identity provider not found | | +| 500 | Failed to find identity provider \| Failed to create identity provider instance \| Failed to exchange token \| Failed to get user info \| Failed to compile identifier filter \| Incorrect login credentials, please try again \| Failed to generate random password \| Failed to generate password hash \| Failed to create user \| Failed to generate tokens \| Failed to create activity | | + +### /api/v1/auth/signout + +#### POST +##### Summary + +Sign-out from memos. + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Sign-out success | boolean | + +### /api/v1/auth/signup + +#### POST +##### Summary + +Sign-up to memos. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Sign-up object | Yes | [v1.SignUp](#v1signup) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User information | [store.User](#storeuser) | +| 400 | Malformatted signup request \| Failed to find users | | +| 401 | signup is disabled | | +| 403 | Forbidden | | +| 404 | Not found | | +| 500 | Failed to find system setting \| Failed to unmarshal system setting allow signup \| Failed to generate password hash \| Failed to create user \| Failed to generate tokens \| Failed to create activity | | + +--- +### /api/v1/idp + +#### GET +##### Summary + +Get a list of identity providers + +##### Description + +*clientSecret is only available for host user + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of available identity providers | [ [v1.IdentityProvider](#v1identityprovider) ] | +| 500 | Failed to find identity provider list \| Failed to find user | | + +#### POST +##### Summary + +Create Identity Provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Identity provider information | Yes | [v1.CreateIdentityProviderRequest](#v1createidentityproviderrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Identity provider information | [store.IdentityProvider](#storeidentityprovider) | +| 400 | Malformatted post identity provider request | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to create identity provider | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/idp/{idpId} + +#### DELETE +##### Summary + +Delete an identity provider by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| idpId | path | Identity Provider ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Identity Provider deleted | boolean | +| 400 | ID is not a number: %s \| Malformatted patch identity provider request | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to patch identity provider | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### GET +##### Summary + +Get an identity provider by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| idpId | path | Identity provider ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Requested identity provider | [store.IdentityProvider](#storeidentityprovider) | +| 400 | ID is not a number: %s | | +| 401 | Missing user in session \| Unauthorized | | +| 404 | Identity provider not found | | +| 500 | Failed to find identity provider list \| Failed to find user | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### PATCH +##### Summary + +Update an identity provider by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| idpId | path | Identity Provider ID | Yes | integer | +| body | body | Patched identity provider information | Yes | [v1.UpdateIdentityProviderRequest](#v1updateidentityproviderrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Patched identity provider | [store.IdentityProvider](#storeidentityprovider) | +| 400 | ID is not a number: %s \| Malformatted patch identity provider request | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to patch identity provider | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/memo + +#### GET +##### Summary + +Get a list of memos matching optional filters + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| creatorId | query | Creator ID | No | integer | +| creatorUsername | query | Creator username | No | string | +| rowStatus | query | Row status | No | string | +| pinned | query | Pinned | No | boolean | +| tag | query | Search for tag. Do not append # | No | string | +| content | query | Search for content | No | string | +| limit | query | Limit | No | integer | +| offset | query | Offset | No | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo list | [ [store.Memo](#storememo) ] | +| 400 | Missing user to find memo | | +| 500 | Failed to get memo display with updated ts setting value \| Failed to fetch memo list \| Failed to compose memo response | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### POST +##### Summary + +Create a memo + +##### Description + +Visibility can be PUBLIC, PROTECTED or PRIVATE +*You should omit fields to use their default values + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.CreateMemoRequest](#v1creatememorequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Stored memo | [store.Memo](#storememo) | +| 400 | Malformatted post memo request \| Content size overflow, up to 1MB | | +| 401 | Missing user in session | | +| 404 | User not found \| Memo not found: %d | | +| 500 | Failed to find user setting \| Failed to unmarshal user setting value \| Failed to find system setting \| Failed to unmarshal system setting \| Failed to find user \| Failed to create memo \| Failed to create activity \| Failed to upsert memo resource \| Failed to upsert memo relation \| Failed to compose memo \| Failed to compose memo response | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/memo/{memoId} + +#### DELETE +##### Summary + +Delete memo by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | Memo ID to delete | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo deleted | boolean | +| 400 | ID is not a number: %s | | +| 401 | Missing user in session \| Unauthorized | | +| 404 | Memo not found: %d | | +| 500 | Failed to find memo \| Failed to delete memo ID: %v | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### GET +##### Summary + +Get memo by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | Memo ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo list | [ [store.Memo](#storememo) ] | +| 400 | ID is not a number: %s | | +| 401 | Missing user in session | | +| 403 | this memo is private only \| this memo is protected, missing user in session | | +| 404 | Memo not found: %d | | +| 500 | Failed to find memo by ID: %v \| Failed to compose memo response | | + +#### PATCH +##### Summary + +Update a memo + +##### Description + +Visibility can be PUBLIC, PROTECTED or PRIVATE +*You should omit fields to use their default values + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to update | Yes | integer | +| body | body | Patched object. | Yes | [v1.PatchMemoRequest](#v1patchmemorequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Stored memo | [store.Memo](#storememo) | +| 400 | ID is not a number: %s \| Malformatted patch memo request \| Content size overflow, up to 1MB | | +| 401 | Missing user in session \| Unauthorized | | +| 404 | Memo not found: %d | | +| 500 | Failed to find memo \| Failed to patch memo \| Failed to upsert memo resource \| Failed to delete memo resource \| Failed to compose memo response | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/memo/all + +#### GET +##### Summary + +Get a list of public memos matching optional filters + +##### Description + +This should also list protected memos if the user is logged in +Authentication is optional + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | Limit | No | integer | +| offset | query | Offset | No | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo list | [ [store.Memo](#storememo) ] | +| 500 | Failed to get memo display with updated ts setting value \| Failed to fetch all memo list \| Failed to compose memo response | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/memo/stats + +#### GET +##### Summary + +Get memo stats by creator ID or username + +##### Description + +Used to generate the heatmap + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| creatorId | query | Creator ID | No | integer | +| creatorUsername | query | Creator username | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo createdTs list | [ integer ] | +| 400 | Missing user id to find memo | | +| 500 | Failed to get memo display with updated ts setting value \| Failed to find memo list \| Failed to compose memo response | | + +--- +### /api/v1/memo/{memoId}/organizer + +#### POST +##### Summary + +Organize memo (pin/unpin) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to organize | Yes | integer | +| body | body | Memo organizer object | Yes | [v1.UpsertMemoOrganizerRequest](#v1upsertmemoorganizerrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo information | [store.Memo](#storememo) | +| 400 | ID is not a number: %s \| Malformatted post memo organizer request | | +| 401 | Missing user in session \| Unauthorized | | +| 404 | Memo not found: %v | | +| 500 | Failed to find memo \| Failed to upsert memo organizer \| Failed to find memo by ID: %v \| Failed to compose memo response | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/memo/{memoId}/relation + +#### GET +##### Summary + +Get a list of Memo Relations + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to find relations | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo relation information list | [ [store.MemoRelation](#storememorelation) ] | +| 400 | ID is not a number: %s | | +| 500 | Failed to list memo relations | | + +#### POST +##### Summary + +Create Memo Relation + +##### Description + +Create a relation between two memos + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to relate | Yes | integer | +| body | body | Memo relation object | Yes | [v1.UpsertMemoRelationRequest](#v1upsertmemorelationrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo relation information | [store.MemoRelation](#storememorelation) | +| 400 | ID is not a number: %s \| Malformatted post memo relation request | | +| 500 | Failed to upsert memo relation | | + +### /api/v1/memo/{memoId}/relation/{relatedMemoId}/type/{relationType} + +#### DELETE +##### Summary + +Delete a Memo Relation + +##### Description + +Removes a relation between two memos + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to find relations | Yes | integer | +| relatedMemoId | path | ID of memo to remove relation to | Yes | integer | +| relationType | path | Type of relation to remove | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo relation deleted | boolean | +| 400 | Memo ID is not a number: %s \| Related memo ID is not a number: %s | | +| 500 | Failed to delete memo relation | | + +--- +### /api/v1/memo/{memoId}/resource + +#### GET +##### Summary + +Get resource list of a memo + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to fetch resource list from | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo resource list | [ [v1.Resource](#v1resource) ] | +| 400 | ID is not a number: %s | | +| 500 | Failed to fetch resource list | | + +#### POST +##### Summary + +Bind resource to memo + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to bind resource to | Yes | integer | +| body | body | Memo resource request object | Yes | [v1.UpsertMemoResourceRequest](#v1upsertmemoresourcerequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo resource binded | boolean | +| 400 | ID is not a number: %s \| Malformatted post memo resource request \| Resource not found | | +| 401 | Missing user in session \| Unauthorized to bind this resource | | +| 500 | Failed to fetch resource \| Failed to upsert memo resource | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/memo/{memoId}/resource/{resourceId} + +#### DELETE +##### Summary + +Unbind resource from memo + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| memoId | path | ID of memo to unbind resource from | Yes | integer | +| resourceId | path | ID of resource to unbind from memo | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Memo resource unbinded. *200 is returned even if the reference doesn't exists | boolean | +| 400 | Memo ID is not a number: %s \| Resource ID is not a number: %s \| Memo not found | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find memo \| Failed to fetch resource list | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/ping + +#### GET +##### Summary + +Ping the system + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System profile | [profile.Profile](#profileprofile) | + +### /api/v1/status + +#### GET +##### Summary + +Get system status + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System status | [v1.SystemStatus](#v1systemstatus) | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find host user \| Failed to find system setting list \| Failed to unmarshal system setting customized profile value | | + +### /api/v1/system/vacuum + +#### POST +##### Summary + +Vacuum the database + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Database vacuumed | boolean | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to vacuum database | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/resource + +#### GET +##### Summary + +Get a list of resources + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | Limit | No | integer | +| offset | query | Offset | No | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Resource list | [ [store.Resource](#storeresource) ] | +| 401 | Missing user in session | | +| 500 | Failed to fetch resource list | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### POST +##### Summary + +Create resource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.CreateResourceRequest](#v1createresourcerequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created resource | [store.Resource](#storeresource) | +| 400 | Malformatted post resource request \| Invalid external link \| Invalid external link scheme \| Failed to request %s \| Failed to read %s \| Failed to read mime from %s | | +| 401 | Missing user in session | | +| 500 | Failed to save resource \| Failed to create resource \| Failed to create activity | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/resource/{resourceId} + +#### DELETE +##### Summary + +Delete a resource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resourceId | path | Resource ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Resource deleted | boolean | +| 400 | ID is not a number: %s | | +| 401 | Missing user in session | | +| 404 | Resource not found: %d | | +| 500 | Failed to find resource \| Failed to delete resource | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### PATCH +##### Summary + +Update a resource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resourceId | path | Resource ID | Yes | integer | +| patch | body | Patch resource request | Yes | [v1.UpdateResourceRequest](#v1updateresourcerequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Updated resource | [store.Resource](#storeresource) | +| 400 | ID is not a number: %s \| Malformatted patch resource request | | +| 401 | Missing user in session \| Unauthorized | | +| 404 | Resource not found: %d | | +| 500 | Failed to find resource \| Failed to patch resource | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/resource/blob + +#### POST +##### Summary + +Upload resource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| file | formData | File to upload | Yes | file | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created resource | [store.Resource](#storeresource) | +| 400 | Upload file not found \| File size exceeds allowed limit of %d MiB \| Failed to parse upload data | | +| 401 | Missing user in session | | +| 500 | Failed to get uploading file \| Failed to open file \| Failed to save resource \| Failed to create resource \| Failed to create activity | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /o/r/{resourceId} + +#### GET +##### Summary + +Stream a resource + +##### Description + +*Swagger UI may have problems displaying other file types than images + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resourceId | path | Resource ID | Yes | integer | +| thumbnail | query | Thumbnail | No | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Requested resource | +| 400 | ID is not a number: %s \| Failed to get resource visibility | +| 401 | Resource visibility not match | +| 404 | Resource not found: %d | +| 500 | Failed to find resource by ID: %v \| Failed to open the local resource: %s \| Failed to read the local resource: %s | + +--- +### /api/v1/storage + +#### GET +##### Summary + +Get a list of storages + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of storages | [ [store.Storage](#storestorage) ] | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to convert storage | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### POST +##### Summary + +Create storage + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.CreateStorageRequest](#v1createstoragerequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created storage | [store.Storage](#storestorage) | +| 400 | Malformatted post storage request | | +| 401 | Missing user in session | | +| 500 | Failed to find user \| Failed to create storage \| Failed to convert storage | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/storage/{storageId} + +#### DELETE +##### Summary + +Delete a storage + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| storageId | path | Storage ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Storage deleted | boolean | +| 400 | ID is not a number: %s \| Storage service %d is using | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to find storage \| Failed to unmarshal storage service id \| Failed to delete storage | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### PATCH +##### Summary + +Update a storage + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| storageId | path | Storage ID | Yes | integer | +| patch | body | Patch request | Yes | [v1.UpdateStorageRequest](#v1updatestoragerequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Updated resource | [store.Storage](#storestorage) | +| 400 | ID is not a number: %s \| Malformatted patch storage request \| Malformatted post storage request | | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to patch storage \| Failed to convert storage | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/system/setting + +#### GET +##### Summary + +Get a list of system settings + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System setting list | [ [v1.SystemSetting](#v1systemsetting) ] | +| 401 | Missing user in session \| Unauthorized | | +| 500 | Failed to find user \| Failed to find system setting list | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### POST +##### Summary + +Create system setting + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.UpsertSystemSettingRequest](#v1upsertsystemsettingrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created system setting | [store.SystemSetting](#storesystemsetting) | +| 400 | Malformatted post system setting request \| invalid system setting | | +| 401 | Missing user in session \| Unauthorized | | +| 403 | Cannot disable passwords if no SSO identity provider is configured. | | +| 500 | Failed to find user \| Failed to upsert system setting | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/tag + +#### GET +##### Summary + +Get a list of tags + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tag list | [ string ] | +| 400 | Missing user id to find tag | | +| 500 | Failed to find tag list | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +#### POST +##### Summary + +Create a tag + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.UpsertTagRequest](#v1upserttagrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created tag name | string | +| 400 | Malformatted post tag request \| Tag name shouldn't be empty | | +| 401 | Missing user in session | | +| 500 | Failed to upsert tag \| Failed to create activity | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/tag/delete + +#### POST +##### Summary + +Delete a tag + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.DeleteTagRequest](#v1deletetagrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tag deleted | boolean | +| 400 | Malformatted post tag request \| Tag name shouldn't be empty | | +| 401 | Missing user in session | | +| 500 | Failed to delete tag name: %v | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/tag/suggestion + +#### GET +##### Summary + +Get a list of tags suggested from other memos contents + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tag list | [ string ] | +| 400 | Missing user session | | +| 500 | Failed to find memo list \| Failed to find tag list | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /api/v1/user + +#### GET +##### Summary + +Get a list of users + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User list | [ [store.User](#storeuser) ] | +| 500 | Failed to fetch user list | | + +#### POST +##### Summary + +Create a user + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object | Yes | [v1.CreateUserRequest](#v1createuserrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created user | [store.User](#storeuser) | +| 400 | Malformatted post user request \| Invalid user create format | | +| 401 | Missing auth session \| Unauthorized to create user | | +| 403 | Could not create host user | | +| 500 | Failed to find user by id \| Failed to generate password hash \| Failed to create user \| Failed to create activity | | + +### /api/v1/user/{id} + +#### DELETE +##### Summary + +Delete a user + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | User ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User deleted | boolean | +| 400 | ID is not a number: %s \| Current session user not found with ID: %d | | +| 401 | Missing user in session | | +| 403 | Unauthorized to delete user | | +| 500 | Failed to find user \| Failed to delete user | | + +#### GET +##### Summary + +Get user by id + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | User ID | Yes | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Requested user | [store.User](#storeuser) | +| 400 | Malformatted user id | | +| 404 | User not found | | +| 500 | Failed to find user | | + +#### PATCH +##### Summary + +Update a user + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | User ID | Yes | string | +| patch | body | Patch request | Yes | [v1.UpdateUserRequest](#v1updateuserrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Updated user | [store.User](#storeuser) | +| 400 | ID is not a number: %s \| Current session user not found with ID: %d \| Malformatted patch user request \| Invalid update user request | | +| 401 | Missing user in session | | +| 403 | Unauthorized to update user | | +| 500 | Failed to find user \| Failed to generate password hash \| Failed to patch user \| Failed to find userSettingList | | + +### /api/v1/user/me + +#### GET +##### Summary + +Get current user + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Current user | [store.User](#storeuser) | +| 401 | Missing auth session | | +| 500 | Failed to find user \| Failed to find userSettingList | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +### /api/v1/user/name/{username} + +#### GET +##### Summary + +Get user by username + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| username | path | Username | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Requested user | [store.User](#storeuser) | +| 404 | User not found | | +| 500 | Failed to find user | | + +--- +### /api/v1/user/setting + +#### POST +##### Summary + +Create user setting + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| body | body | Request object. | Yes | [v1.UpsertUserSettingRequest](#v1upsertusersettingrequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Created user setting | [store.UserSetting](#storeusersetting) | +| 400 | Malformatted post user setting upsert request \| Invalid user setting format | | +| 401 | Missing auth session | | +| 500 | Failed to upsert user setting | | + +##### Security + +| Security Schema | Scopes | +| --------------- | ------ | +| ApiKeyAuth | | + +--- +### /explore/rss.xml + +#### GET +##### Summary + +Get RSS + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | RSS | +| 500 | Failed to get system customized profile \| Failed to find memo list \| Failed to generate rss | + +### /u/{id}/rss.xml + +#### GET +##### Summary + +Get RSS for a user + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | User ID | Yes | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | RSS | +| 400 | User id is not a number | +| 500 | Failed to get system customized profile \| Failed to find memo list \| Failed to generate rss | + +--- +### /o/get/httpmeta + +#### GET +##### Summary + +Get website metadata + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | query | Website URL | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Extracted metadata | [getter.HTMLMeta](#getterhtmlmeta) | +| 400 | Missing website url \| Wrong url | | +| 406 | Failed to get website meta with url: %s | | + +### /o/get/image + +#### GET +##### Summary + +Get image from URL + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | query | Image url | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Image | +| 400 | Missing image url \| Wrong url \| Failed to get image url: %s | +| 500 | Failed to write image blob | + +--- +### Models + +#### getter.HTMLMeta + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| image | string | | No | +| title | string | | No | + +#### profile.Profile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string | Mode can be "prod" or "dev" or "demo" | No | +| version | string | Version is the current version of server | No | + +#### store.FieldMapping + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| displayName | string | | No | +| email | string | | No | +| identifier | string | | No | + +#### store.IdentityProvider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [store.IdentityProviderConfig](#storeidentityproviderconfig) | | No | +| id | integer | | No | +| identifierFilter | string | | No | +| name | string | | No | +| type | [store.IdentityProviderType](#storeidentityprovidertype) | | No | + +#### store.IdentityProviderConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| oauth2Config | [store.IdentityProviderOAuth2Config](#storeidentityprovideroauth2config) | | No | + +#### store.IdentityProviderOAuth2Config + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authUrl | string | | No | +| clientId | string | | No | +| clientSecret | string | | No | +| fieldMapping | [store.FieldMapping](#storefieldmapping) | | No | +| scopes | [ string ] | | No | +| tokenUrl | string | | No | +| userInfoUrl | string | | No | + +#### store.IdentityProviderType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| store.IdentityProviderType | string | | | + +#### store.Memo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Domain specific fields | No | +| createdTs | integer | | No | +| creatorID | integer | | No | +| id | integer | | No | +| pinned | boolean | Composed fields | No | +| relationList | [ [store.MemoRelation](#storememorelation) ] | | No | +| resourceIDList | [ integer ] | | No | +| rowStatus | [store.RowStatus](#storerowstatus) | Standard fields | No | +| updatedTs | integer | | No | +| visibility | [store.Visibility](#storevisibility) | | No | + +#### store.MemoRelation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| memoID | integer | | No | +| relatedMemoID | integer | | No | +| type | [store.MemoRelationType](#storememorelationtype) | | No | + +#### store.MemoRelationType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| store.MemoRelationType | string | | | + +#### store.Resource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| blob | [ integer ] | | No | +| createdTs | integer | | No | +| creatorID | integer | Standard fields | No | +| externalLink | string | | No | +| filename | string | Domain specific fields | No | +| id | integer | | No | +| internalPath | string | | No | +| linkedMemoAmount | integer | | No | +| size | integer | | No | +| type | string | | No | +| updatedTs | integer | | No | + +#### store.Role + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| store.Role | string | | | + +#### store.RowStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| store.RowStatus | string | | | + +#### store.Storage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | string | | No | +| id | integer | | No | +| name | string | | No | +| type | string | | No | + +#### store.SystemSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| name | string | | No | +| value | string | | No | + +#### store.User + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatarURL | string | | No | +| createdTs | integer | | No | +| email | string | | No | +| id | integer | | No | +| nickname | string | | No | +| openID | string | | No | +| passwordHash | string | | No | +| role | [store.Role](#storerole) | | No | +| rowStatus | [store.RowStatus](#storerowstatus) | Standard fields | No | +| updatedTs | integer | | No | +| username | string | Domain specific fields | No | + +#### store.UserSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | | No | +| userID | integer | | No | +| value | string | | No | + +#### store.Visibility + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| store.Visibility | string | | | + +#### v1.CreateIdentityProviderRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [v1.IdentityProviderConfig](#v1identityproviderconfig) | | No | +| identifierFilter | string | | No | +| name | string | | No | +| type | [v1.IdentityProviderType](#v1identityprovidertype) | | No | + +#### v1.CreateMemoRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| createdTs | integer | | No | +| relationList | [ [v1.UpsertMemoRelationRequest](#v1upsertmemorelationrequest) ] | | No | +| resourceIdList | [ integer ] | Related fields | No | +| visibility | [v1.Visibility](#v1visibility) | Domain specific fields | No | + +#### v1.CreateResourceRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| downloadToLocal | boolean | | No | +| externalLink | string | | No | +| filename | string | | No | +| internalPath | string | | No | +| type | string | | No | + +#### v1.CreateStorageRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [v1.StorageConfig](#v1storageconfig) | | No | +| name | string | | No | +| type | [v1.StorageType](#v1storagetype) | | No | + +#### v1.CreateUserRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| nickname | string | | No | +| password | string | | No | +| role | [v1.Role](#v1role) | | No | +| username | string | | No | + +#### v1.CustomizedProfile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| appearance | string | Appearance is the server default appearance. | No | +| description | string | Description is the server description. | No | +| externalUrl | string | ExternalURL is the external url of server. e.g. | No | +| locale | string | Locale is the server default locale. | No | +| logoUrl | string | LogoURL is the url of logo image. | No | +| name | string | Name is the server name, default is `memos` | No | + +#### v1.DeleteTagRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | No | + +#### v1.FieldMapping + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| displayName | string | | No | +| email | string | | No | +| identifier | string | | No | + +#### v1.IdentityProvider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [v1.IdentityProviderConfig](#v1identityproviderconfig) | | No | +| id | integer | | No | +| identifierFilter | string | | No | +| name | string | | No | +| type | [v1.IdentityProviderType](#v1identityprovidertype) | | No | + +#### v1.IdentityProviderConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| oauth2Config | [v1.IdentityProviderOAuth2Config](#v1identityprovideroauth2config) | | No | + +#### v1.IdentityProviderOAuth2Config + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authUrl | string | | No | +| clientId | string | | No | +| clientSecret | string | | No | +| fieldMapping | [v1.FieldMapping](#v1fieldmapping) | | No | +| scopes | [ string ] | | No | +| tokenUrl | string | | No | +| userInfoUrl | string | | No | + +#### v1.IdentityProviderType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.IdentityProviderType | string | | | + +#### v1.MemoRelationType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.MemoRelationType | string | | | + +#### v1.PatchMemoRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Domain specific fields | No | +| createdTs | integer | Standard fields | No | +| relationList | [ [v1.UpsertMemoRelationRequest](#v1upsertmemorelationrequest) ] | | No | +| resourceIdList | [ integer ] | Related fields | No | +| rowStatus | [v1.RowStatus](#v1rowstatus) | | No | +| updatedTs | integer | | No | +| visibility | [v1.Visibility](#v1visibility) | | No | + +#### v1.Resource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| createdTs | integer | | No | +| creatorId | integer | Standard fields | No | +| externalLink | string | | No | +| filename | string | Domain specific fields | No | +| id | integer | | No | +| linkedMemoAmount | integer | Related fields | No | +| size | integer | | No | +| type | string | | No | +| updatedTs | integer | | No | + +#### v1.Role + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.Role | string | | | + +#### v1.RowStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.RowStatus | string | | | + +#### v1.SSOSignIn + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | No | +| identityProviderId | integer | | No | +| redirectUri | string | | No | + +#### v1.SignIn + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| password | string | | No | +| username | string | | No | + +#### v1.SignUp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| password | string | | No | +| username | string | | No | + +#### v1.StorageConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| s3Config | [v1.StorageS3Config](#v1storages3config) | | No | + +#### v1.StorageS3Config + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accessKey | string | | No | +| bucket | string | | No | +| endPoint | string | | No | +| path | string | | No | +| region | string | | No | +| secretKey | string | | No | +| urlPrefix | string | | No | +| urlSuffix | string | | No | + +#### v1.StorageType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.StorageType | string | | | + +#### v1.SystemSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| name | [v1.SystemSettingName](#v1systemsettingname) | | No | +| value | string | Value is a JSON string with basic value. | No | + +#### v1.SystemSettingName + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.SystemSettingName | string | | | + +#### v1.SystemStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| additionalScript | string | Additional script. | No | +| additionalStyle | string | Additional style. | No | +| allowSignUp | boolean | System settings Allow sign up. | No | +| autoBackupInterval | integer | Auto Backup Interval. | No | +| customizedProfile | [v1.CustomizedProfile](#v1customizedprofile) | Customized server profile, including server name and external url. | No | +| dbSize | integer | | No | +| disablePasswordLogin | boolean | Disable password login. | No | +| disablePublicMemos | boolean | Disable public memos. | No | +| host | [v1.User](#v1user) | | No | +| localStoragePath | string | Local storage path. | No | +| maxUploadSizeMiB | integer | Max upload size. | No | +| memoDisplayWithUpdatedTs | boolean | Memo display with updated timestamp. | No | +| profile | [profile.Profile](#profileprofile) | | No | +| storageServiceId | integer | Storage service ID. | No | + +#### v1.UpdateIdentityProviderRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [v1.IdentityProviderConfig](#v1identityproviderconfig) | | No | +| identifierFilter | string | | No | +| name | string | | No | +| type | [v1.IdentityProviderType](#v1identityprovidertype) | | No | + +#### v1.UpdateResourceRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| filename | string | | No | + +#### v1.UpdateStorageRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [v1.StorageConfig](#v1storageconfig) | | No | +| name | string | | No | +| type | [v1.StorageType](#v1storagetype) | | No | + +#### v1.UpdateUserRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatarUrl | string | | No | +| email | string | | No | +| nickname | string | | No | +| password | string | | No | +| resetOpenId | boolean | | No | +| rowStatus | [v1.RowStatus](#v1rowstatus) | | No | +| username | string | | No | + +#### v1.UpsertMemoOrganizerRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| pinned | boolean | | No | + +#### v1.UpsertMemoRelationRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| relatedMemoId | integer | | No | +| type | [v1.MemoRelationType](#v1memorelationtype) | | No | + +#### v1.UpsertMemoResourceRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| resourceId | integer | | No | +| updatedTs | integer | | No | + +#### v1.UpsertSystemSettingRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| name | [v1.SystemSettingName](#v1systemsettingname) | | No | +| value | string | | No | + +#### v1.UpsertTagRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | No | + +#### v1.UpsertUserSettingRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | [v1.UserSettingKey](#v1usersettingkey) | | No | +| value | string | | No | + +#### v1.User + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatarUrl | string | | No | +| createdTs | integer | | No | +| email | string | | No | +| id | integer | | No | +| nickname | string | | No | +| openId | string | | No | +| role | [v1.Role](#v1role) | | No | +| rowStatus | [v1.RowStatus](#v1rowstatus) | Standard fields | No | +| updatedTs | integer | | No | +| userSettingList | [ [v1.UserSetting](#v1usersetting) ] | | No | +| username | string | Domain specific fields | No | + +#### v1.UserSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | [v1.UserSettingKey](#v1usersettingkey) | | No | +| userId | integer | | No | +| value | string | | No | + +#### v1.UserSettingKey + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.UserSettingKey | string | | | + +#### v1.Visibility + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| v1.Visibility | string | | | diff --git a/docs/documenting-the-api.md b/docs/documenting-the-api.md new file mode 100644 index 00000000..d5eaeab7 --- /dev/null +++ b/docs/documenting-the-api.md @@ -0,0 +1,113 @@ +# Documenting the API + +## Principles + +- The documentation is generated by [swaggo/swag](https://github.com/swaggo/swag) from comments in the API code. + +- Documentation is written using [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format). + +- The documentation is generated in the `./api` folder as `docs.go`. + +- [echo-swagger](https://github.com/swaggo/echo-swagger) is used to integrate with Echo framework and serve the documentation with [Swagger-UI](https://swagger.io/tools/swagger-ui/) at `http://memos.host:5230/api/index.html` + +## Updating the documentation + +1. Update or add API-related comments in the code. Make sure to follow the [Declarative Comments Format](https://github.com/swaggo/swag#declarative-comments-format): + + ```go + // signIn godoc + // + // @Summary Sign-in to memos. + // @Tags auth + // @Accept json + // @Produce json + // @Param body body SignIn true "Sign-in object" + // @Success 200 {object} store.User "User information" + // @Failure 400 {object} nil "Malformatted signin request" + // @Failure 401 {object} nil "Password login is deactivated | Incorrect login credentials, please try again" + // @Failure 403 {object} nil "User has been archived with username {username}" + // @Failure 500 {object} nil "Failed to find system setting | Failed to unmarshal system setting | Incorrect login credentials, please try again | Failed to generate tokens | Failed to create activity" + // @Router /api/v1/auth/signin [POST] + func (s *APIV1Service) signIn(c echo.Context) error { + ... + ``` + + > Sample from [api/v1/auth.go](https://github.com/usememos/memos/tree/main/api/v1/auth.go) + > You can check existing comments at [api/v1](https://github.com/usememos/memos/tree/main/api/v1) + +2. Run one of the following provided scripts: + + - Linux: `./scripts/generate-api-documentation.sh` (remember to `chmod +x` the script first) + - Windows: `./scripts/generate-api-documentation.ps1` + + > The scripts will install swag if needed (via go install), then run `swag fmt` and `swag init` commands. + +3. That's it! The documentation is updated. You can check it at `http://memos.host:5230/api/index.html` + +### Extra tips + +- If you reference a custom Go struct from outside the API file, use a relative definition, like `store.IdentityProvider`. This works because `./` is passed to swag at `--dir` argument. If swag can't resolve the reference, it will fail. + +- If the API grows or you need to reference some type from another location, remember to update ./scripts/generate-api-documentation.cfg file with the new paths. + +- It's possible to list multiple errors for the same code using enum-like structs, that will show a proper, spec-conformant model with all entries at Swagger-UI. The drawback is that this approach requires a major refactoring and will add a lot of boilerplate code, as there are inconsistencies between API methods error responses. + + ```go + type signInInternalServerError string + + const signInErrorFailedToFindSystemSetting signInInternalServerError = "Failed to find system setting" + const signInErrorFailedToUnmarshalSystemSetting signInInternalServerError = "Failed to unmarshal system setting" + const signInErrorIncorrectLoginCredentials signInInternalServerError = "Incorrect login credentials, please try again" + const signInErrorFailedToGenerateTokens signInInternalServerError = "Failed to generate tokens" + const signInErrorFailedToCreateActivity signInInternalServerError = "Failed to create activity" + + type signInUnauthorized string + + const signInErrorPasswordLoginDeactivated signInUnauthorized = "Password login is deactivated" + const signInErrorIncorrectCredentials signInUnauthorized = "Incorrect login credentials, please try again" + + // signIn godoc + // + // @Summary Sign-in to memos. + // @Tags auth + // @Accept json + // @Produce json + // @Param body body SignIn true "Sign-in object" + // @Success 200 {object} store.User "User information" + // @Failure 400 {object} nil "Malformatted signin request" + // @Failure 401 {object} signInUnauthorized + // @Failure 403 {object} nil "User has been archived with username {username}" + // @Failure 500 {object} signInInternalServerError + // @Router /api/v1/auth/signin [POST] + func (s *APIV1Service) signIn(c echo.Context) error { + ... + ``` + +### Step-by-step (no scripts) + +#### Required tools + +```bash +# Swag v1.8.12 or newer +# Also updates swag if needed +$ go install github.com/swaggo/swag/cmd/swag@latest +``` + +If `$HOME/go/bin` is not in your `PATH`, you can call `swag` directly at `$HOME/go/bin/swag`. + +#### Generate the documentation + +1. Run `swag fmt` to format the comments + + ```bash + swag fmt --dir ./api/v1 && go fmt + ``` + +2. Run `swag init` to generate the documentation + + ```bash + cd + swag init --output api --generalInfo ./server/server.go --dir ./,./api/v1 + ``` + +> If the API gets a new version, you'll need to add the file system path to swag's `--dir` parameter. diff --git a/go.mod b/go.mod index 336a0857..634afe38 100644 --- a/go.mod +++ b/go.mod @@ -13,17 +13,19 @@ require ( github.com/google/uuid v1.3.0 github.com/gorilla/feeds v1.1.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 - github.com/labstack/echo/v4 v4.9.0 + github.com/labstack/echo/v4 v4.11.1 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.15.0 - github.com/stretchr/testify v1.8.1 + github.com/stretchr/testify v1.8.4 + github.com/swaggo/echo-swagger v1.4.0 + github.com/swaggo/swag v1.16.1 github.com/yuin/goldmark v1.5.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.11.0 + golang.org/x/crypto v0.12.0 golang.org/x/exp v0.0.0-20230111222715-75897c7a292a - golang.org/x/mod v0.8.0 - golang.org/x/net v0.12.0 + golang.org/x/mod v0.12.0 + golang.org/x/net v0.14.0 golang.org/x/oauth2 v0.10.0 google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e google.golang.org/grpc v1.57.0 @@ -31,14 +33,22 @@ require ( ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-openapi/jsonpointer v0.20.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/spec v0.20.9 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect golang.org/x/image v0.7.0 // indirect - golang.org/x/tools v0.6.0 // indirect + golang.org/x/tools v0.11.1 // indirect google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e // indirect lukechampine.com/uint128 v1.2.0 // indirect @@ -75,10 +85,10 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/labstack/gommon v0.3.1 // indirect + github.com/labstack/gommon v0.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -88,12 +98,12 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.1 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect - golang.org/x/sys v0.10.0 // indirect - golang.org/x/text v0.11.0 // indirect - golang.org/x/time v0.1.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect + golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index cbc9b25d..3c0af338 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY= @@ -90,6 +92,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -109,6 +112,21 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= +github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8= +github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= @@ -195,6 +213,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -202,25 +222,34 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY= -github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= -github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= -github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= +github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -257,17 +286,26 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/swaggo/echo-swagger v1.4.0 h1:RCxLKySw1SceHLqnmc41pKyiIeE+OiD7NSI7FUOBlLo= +github.com/swaggo/echo-swagger v1.4.0/go.mod h1:Wh3VlwjZGZf/LH0s81tz916JokuPG7y/ZqaqnckYqoQ= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg= +github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -297,8 +335,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -338,8 +376,9 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -373,8 +412,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -397,8 +436,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -441,8 +480,9 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -455,13 +495,13 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= -golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -510,8 +550,9 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc= +golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -619,14 +660,18 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/generate-api-documentation.cfg b/scripts/generate-api-documentation.cfg new file mode 100644 index 00000000..c02b20a6 --- /dev/null +++ b/scripts/generate-api-documentation.cfg @@ -0,0 +1,13 @@ +# This file is used by generate-api-documentation.ps1 and generate-api-documentation.sh + +# You should list aditional dirs here if the API grows +SWAG_API_DIRS=./api/v1 + +# Where general API info is documented +SWAG_GENERAL_INFO=./server/server.go + +# Possible output files: go (docs.go), json (swagger.json), yaml (swagger.yaml) +SWAG_OUTPUT_TYPES=go,yaml + +# Where generated files are outputted +SWAG_OUTPUT=./api diff --git a/scripts/generate-api-documentation.ps1 b/scripts/generate-api-documentation.ps1 new file mode 100644 index 00000000..8dd163a3 --- /dev/null +++ b/scripts/generate-api-documentation.ps1 @@ -0,0 +1,73 @@ +# This script generates API documentation using swaggo/swag + +# For more details, check the docs: +# * https://usememos.com/docs/contribution/development +# * https://github.com/usememos/memos/blob/main/docs/api/documentation.md + +# Requirements: +# * go + +# swag is configured mainly via generate-api-documentation.cfg file. + +# Usage: +# ./scripts/generate-api-documentation.ps1 + +foreach ($dir in @(".", "../")) { + if (Test-Path (Join-Path $dir ".gitignore")) { + $repoRoot = (Resolve-Path $dir).Path + break + } +} +Set-Location $repoRoot + +Write-Host "Parsing generate-api-documentation.cfg..." +foreach ($line in (Get-Content "$repoRoot\scripts\generate-api-documentation.cfg" )) { + if ($line.Trim().StartsWith('#')) { + continue + } + $name, $value = $line.split('=') + if ([string]::IsNullOrWhiteSpace($name)) { + continue + } + Set-Content env:\$name $value +} + +Write-Host "API directories: $env:SWAG_API_DIRS" -f Cyan +Write-Host "Output directory: $env:SWAG_OUTPUT" -f Cyan +Write-Host "General info: $env:SWAG_GENERAL_INFO" -f Cyan + +$swag = (Get-Command swag -ErrorAction SilentlyContinue).Path +if (-not $swag) { + foreach ($path in @((Join-Path $HOME "go/bin"), (Join-Path $env:GOPATH "/bin"))) { + $swag = Join-Path (Resolve-Path $path).Path "swag.exe" + if (Test-Path $swag) { + break + } + } +} +if (-not (Test-Path $swag)) { + Write-Host "Swag is not installed. Installing..." -f Magenta + go install github.com/swaggo/swag/cmd/swag@latest +} + +$generalInfoPath = (Split-Path (Resolve-Path $env:SWAG_GENERAL_INFO -Relative) -Parent) +$apiDirs = $env:SWAG_API_DIRS -split ',' | ForEach-Object { "$(Resolve-Path $_ -Relative)" } +$swagFmtDirs = $generalInfoPath + "," + $($apiDirs -join ",") + +Write-Host "Formatting comments via ``swag fmt --dir `"$swagFmtDirs`"``..." -f Magenta +&$swag fmt --dir "`"${swagFmtDirs}`"" + +$goFmtDirs = $swagFmtDirs -split ',' | ForEach-Object { "`"$($_)`"" } + +# This is just in case swag fmt do something non-conforming to go fmt +Write-Host "Formatting code via ``go fmt ${goFmtDirs}``..." -f Magenta +go fmt ${goFmtDirs} + +Write-Host "Generating Swagger API documentation..." -f Magenta +&$swag init --output $env:SWAG_OUTPUT --outputTypes $env:SWAG_OUTPUT_TYPES --generalInfo $env:SWAG_GENERAL_INFO --dir "./,${env:SWAG_API_DIRS}" + +if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to generate API documentation!" -f Red + exit $LASTEXITCODE +} +Write-Host "API documentation updated!" -f Green diff --git a/scripts/generate-api-documentation.sh b/scripts/generate-api-documentation.sh new file mode 100755 index 00000000..d606d144 --- /dev/null +++ b/scripts/generate-api-documentation.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# This script generates API documentation using swaggo/swag + +# For more details, check the docs: +# * https://usememos.com/docs/contribution/development +# * https://github.com/usememos/memos/blob/main/docs/api/documentation.md + +# Requirements: +# * go + +# swag is configured via generate-api-documentation.cfg file. + +# Usage: +# chmod +x ./scripts/generate-api-documentation.sh +# ./scripts/generate-api-documentation.sh + +find_repo_root() { + # Usage: find_repo_root ... + local looking_for="${1:-".gitignore"}" + shift + local default_dirs=("." "../") + local dirs=("${@:-${default_dirs[@]}}") + for dir in "${dirs[@]}"; do + if [ -f "$dir/$looking_for" ]; then + echo $(realpath "$dir") + return + fi + done +} + +find_binary() { + # Usage: find_binary ... + local looking_for="$1" + shift + local default_dirs=(".") + + local binary=$(command -v $looking_for) + if [ ! -z "$binary" ]; then + echo "$binary" + return + fi + + local dirs=("${@:-${default_dirs[@]}}") + for dir in "${dirs[@]}"; do + if [ -f "$dir/$looking_for" ]; then + echo $(realpath "$dir")/$looking_for + return + fi + done +} + +repo_root=$(find_repo_root) +if [ -z "$repo_root" ]; then + echo -e "\033[0;31mRepository root not found! Exiting.\033[0m" + exit 1 +else + echo -e "Repository root: \033[0;34m$repo_root\033[0m" +fi +cd $repo_root + +echo "Parsing generate-api-documentation.cfg..." +source "$repo_root/scripts/generate-api-documentation.cfg" + +echo -e "API directories: \033[0;34m$SWAG_API_DIRS\033[0m" +echo -e "Output directory: \033[0;34m$SWAG_OUTPUT\033[0m" +echo -e "General info: \033[0;34m$SWAG_GENERAL_INFO\033[0m" + +if [ -z "$SWAG_API_DIRS" ]; then + echo -e "\033[0;31mAPI directories not set! Exiting.\033[0m" + exit 1 +fi + +swag=$(find_binary swag "$HOME/go/bin" "$GOPATH/bin") +if [ -z "$swag" ]; then + echo "Swag is not installed. Installing..." + go install github.com/swaggo/swag/cmd/swag@latest + swag=$(find_binary swag "$HOME/go/bin" "$GOPATH/bin") +fi + +if [ -z "$swag" ]; then + echo -e "\033[0;31mSwag binary not found! Exiting.\033[0m" + exit 1 +fi +echo -e "Swag binary: \033[0;34m$swag\033[0m" + +general_info_path=$(dirname "$SWAG_GENERAL_INFO") +if [ ! -d "$general_info_path" ]; then + echo -e "\033[0;31mGeneral info directory does not exist!\033[0m" + exit 1 +fi + +echo -e "\e[35mFormatting comments via \`swag fmt --dir "$general_info_path,$SWAG_API_DIRS"\`...\e[0m" +$swag fmt --dir "$general_info_path,$SWAG_API_DIRS" + +# This is just in case "swag fmt" do something non-conforming to "go fmt" +go_fmt_dirs=$(echo $general_info_path $SWAG_API_DIRS | tr "," " ") +echo -e "\e[35mFormatting code via \`go fmt $go_fmt_dirs\`...\e[0m" +go fmt $go_fmt_dirs + +echo -e "\e[35mGenerating Swagger API documentation...\e[0m" +$swag init --output "$SWAG_OUTPUT" --outputTypes "$SWAG_OUTPUT_TYPES" --generalInfo "$SWAG_GENERAL_INFO" --dir "./,$SWAG_API_DIRS" + +if [ $? -ne 0 ]; then + echo -e "\033[0;31mFailed to generate Swagger API documentation!\033[0m" + exit 1 +fi +echo -e "\033[0;32mSwagger API documentation updated!\033[0m" diff --git a/server/server.go b/server/server.go index b3e0173e..292a89b7 100644 --- a/server/server.go +++ b/server/server.go @@ -12,6 +12,8 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/pkg/errors" + echoSwagger "github.com/swaggo/echo-swagger" + api "github.com/usememos/memos/api" apiv1 "github.com/usememos/memos/api/v1" apiv2 "github.com/usememos/memos/api/v2" "github.com/usememos/memos/common/log" @@ -38,7 +40,29 @@ type Server struct { telegramBot *telegram.Bot } +// @title memos API +// @version 1.0 +// @description A privacy-first, lightweight note-taking service. +// +// @contact.name API Support +// @contact.url https://github.com/orgs/usememos/discussions +// +// @license.name MIT License +// @license.url https://github.com/usememos/memos/blob/main/LICENSE +// +// @BasePath / +// +// @externalDocs.url https://usememos.com/ +// @externalDocs.description Find out more about Memos +// +// @securitydefinitions.apikey ApiKeyAuth +// @in query +// @name openId +// @description Insert your Open ID API Key here. func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store) (*Server, error) { + // programmatically set API version same as the server version + api.SwaggerInfo.Version = profile.Version + e := echo.New() e.Debug = true e.HideBanner = true @@ -85,6 +109,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store embedFrontend(e) + // This will serve Swagger UI at /api/index.html and Swagger 2.0 spec at /api/doc.json + e.GET("/api/*", echoSwagger.WrapHandler) + secret := "usememos" if profile.Mode == "prod" { secret, err = s.getSystemSecretSessionName(ctx)