diff --git a/api-docs/openapi/v3_0/aggregated.json b/api-docs/openapi/v3_0/aggregated.json index 5a6dd3ca99..b491544c22 100644 --- a/api-docs/openapi/v3_0/aggregated.json +++ b/api-docs/openapi/v3_0/aggregated.json @@ -6918,6 +6918,48 @@ ] } }, + "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri": { + "get": { + "description": "Get thumbnail by URI", + "operationId": "GetThumbnailByUri", + "parameters": [ + { + "description": "The URI of the image", + "in": "query", + "name": "uri", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The width of the thumbnail", + "in": "query", + "name": "width", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "ThumbnailV1alpha1Public" + ] + } + }, "/apis/auth.halo.run/v1alpha1/authproviders": { "get": { "description": "List AuthProvider", @@ -13415,6 +13457,243 @@ ] } }, + "/apis/storage.halo.run/v1alpha1/localthumbnails": { + "get": { + "description": "List LocalThumbnail", + "operationId": "listLocalThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnailList" + } + } + }, + "description": "Response localthumbnails" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "post": { + "description": "Create LocalThumbnail", + "operationId": "createLocalThumbnail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Fresh localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails created just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { + "delete": { + "description": "Delete LocalThumbnail", + "operationId": "deleteLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response localthumbnail deleted just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "get": { + "description": "Get LocalThumbnail", + "operationId": "getLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response single localthumbnail" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "patch": { + "description": "Patch LocalThumbnail", + "operationId": "patchLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnail patched just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "put": { + "description": "Update LocalThumbnail", + "operationId": "updateLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Updated localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails updated just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, "/apis/storage.halo.run/v1alpha1/policies": { "get": { "description": "List Policy", @@ -13722,46 +14001,283 @@ } }, "tags": [ - "PolicyTemplateV1alpha1" + "PolicyTemplateV1alpha1" + ] + }, + "post": { + "description": "Create PolicyTemplate", + "operationId": "createPolicyTemplate", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Fresh policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates created just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "delete": { + "description": "Delete PolicyTemplate", + "operationId": "deletePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response policytemplate deleted just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "get": { + "description": "Get PolicyTemplate", + "operationId": "getPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response single policytemplate" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "patch": { + "description": "Patch PolicyTemplate", + "operationId": "patchPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplate patched just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "put": { + "description": "Update PolicyTemplate", + "operationId": "updatePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Updated policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates updated just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/thumbnails": { + "get": { + "description": "List Thumbnail", + "operationId": "listThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThumbnailList" + } + } + }, + "description": "Response thumbnails" + } + }, + "tags": [ + "ThumbnailV1alpha1" ] }, "post": { - "description": "Create PolicyTemplate", - "operationId": "createPolicyTemplate", + "description": "Create Thumbnail", + "operationId": "createThumbnail", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Fresh policytemplate" + "description": "Fresh thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplates created just now" + "description": "Response thumbnails created just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { "delete": { - "description": "Delete PolicyTemplate", - "operationId": "deletePolicyTemplate", + "description": "Delete Thumbnail", + "operationId": "deleteThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -13772,19 +14288,19 @@ ], "responses": { "200": { - "description": "Response policytemplate deleted just now" + "description": "Response thumbnail deleted just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "get": { - "description": "Get PolicyTemplate", - "operationId": "getPolicyTemplate", + "description": "Get Thumbnail", + "operationId": "getThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -13798,23 +14314,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response single policytemplate" + "description": "Response single thumbnail" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "patch": { - "description": "Patch PolicyTemplate", - "operationId": "patchPolicyTemplate", + "description": "Patch Thumbnail", + "operationId": "patchThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -13837,23 +14353,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplate patched just now" + "description": "Response thumbnail patched just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "put": { - "description": "Update PolicyTemplate", - "operationId": "updatePolicyTemplate", + "description": "Update Thumbnail", + "operationId": "updateThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -13866,26 +14382,26 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Updated policytemplate" + "description": "Updated thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplates updated just now" + "description": "Response thumbnails updated just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] } }, @@ -15225,6 +15741,12 @@ "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + }, + "thumbnails": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, @@ -18188,6 +18710,145 @@ }, "description": "A chunk of items." }, + "LocalThumbnail": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/LocalThumbnailSpec" + }, + "status": { + "$ref": "#/components/schemas/LocalThumbnailStatus" + } + } + }, + "LocalThumbnailList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/LocalThumbnail" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "LocalThumbnailSpec": { + "required": [ + "filePath", + "imageSignature", + "imageUri", + "size", + "thumbSignature", + "thumbnailUri" + ], + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "imageSignature": { + "minLength": 1, + "type": "string" + }, + "imageUri": { + "minLength": 1, + "type": "string" + }, + "size": { + "type": "string", + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "thumbSignature": { + "minLength": 1, + "type": "string" + }, + "thumbnailUri": { + "minLength": 1, + "type": "string" + } + } + }, + "LocalThumbnailStatus": { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "PENDING", + "SUCCEEDED", + "FAILED" + ] + } + } + }, "LoginHistory": { "required": [ "loginAt", @@ -22620,6 +23281,120 @@ } } }, + "Thumbnail": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/ThumbnailSpec" + } + } + }, + "ThumbnailList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Thumbnail" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ThumbnailSpec": { + "required": [ + "imageSignature", + "imageUri", + "size", + "thumbnailUri" + ], + "type": "object", + "properties": { + "imageSignature": { + "minLength": 1, + "type": "string" + }, + "imageUri": { + "minLength": 1, + "type": "string" + }, + "size": { + "type": "string", + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "thumbnailUri": { + "minLength": 1, + "type": "string" + } + } + }, "TotpAuthLinkResponse": { "type": "object", "properties": { diff --git a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json index f763704278..8483de34f7 100644 --- a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json @@ -3393,6 +3393,12 @@ "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + }, + "thumbnails": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, diff --git a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json index 8015200160..92018c3729 100644 --- a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json @@ -7127,6 +7127,243 @@ ] } }, + "/apis/storage.halo.run/v1alpha1/localthumbnails": { + "get": { + "description": "List LocalThumbnail", + "operationId": "listLocalThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnailList" + } + } + }, + "description": "Response localthumbnails" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "post": { + "description": "Create LocalThumbnail", + "operationId": "createLocalThumbnail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Fresh localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails created just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { + "delete": { + "description": "Delete LocalThumbnail", + "operationId": "deleteLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response localthumbnail deleted just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "get": { + "description": "Get LocalThumbnail", + "operationId": "getLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response single localthumbnail" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "patch": { + "description": "Patch LocalThumbnail", + "operationId": "patchLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnail patched just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "put": { + "description": "Update LocalThumbnail", + "operationId": "updateLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Updated localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails updated just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, "/apis/storage.halo.run/v1alpha1/policies": { "get": { "description": "List Policy", @@ -7434,46 +7671,283 @@ } }, "tags": [ - "PolicyTemplateV1alpha1" + "PolicyTemplateV1alpha1" + ] + }, + "post": { + "description": "Create PolicyTemplate", + "operationId": "createPolicyTemplate", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Fresh policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates created just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "delete": { + "description": "Delete PolicyTemplate", + "operationId": "deletePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response policytemplate deleted just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "get": { + "description": "Get PolicyTemplate", + "operationId": "getPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response single policytemplate" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "patch": { + "description": "Patch PolicyTemplate", + "operationId": "patchPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplate patched just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "put": { + "description": "Update PolicyTemplate", + "operationId": "updatePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Updated policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates updated just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/thumbnails": { + "get": { + "description": "List Thumbnail", + "operationId": "listThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThumbnailList" + } + } + }, + "description": "Response thumbnails" + } + }, + "tags": [ + "ThumbnailV1alpha1" ] }, "post": { - "description": "Create PolicyTemplate", - "operationId": "createPolicyTemplate", + "description": "Create Thumbnail", + "operationId": "createThumbnail", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Fresh policytemplate" + "description": "Fresh thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplates created just now" + "description": "Response thumbnails created just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { "delete": { - "description": "Delete PolicyTemplate", - "operationId": "deletePolicyTemplate", + "description": "Delete Thumbnail", + "operationId": "deleteThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -7484,19 +7958,19 @@ ], "responses": { "200": { - "description": "Response policytemplate deleted just now" + "description": "Response thumbnail deleted just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "get": { - "description": "Get PolicyTemplate", - "operationId": "getPolicyTemplate", + "description": "Get Thumbnail", + "operationId": "getThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -7510,23 +7984,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response single policytemplate" + "description": "Response single thumbnail" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "patch": { - "description": "Patch PolicyTemplate", - "operationId": "patchPolicyTemplate", + "description": "Patch Thumbnail", + "operationId": "patchThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -7549,23 +8023,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplate patched just now" + "description": "Response thumbnail patched just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] }, "put": { - "description": "Update PolicyTemplate", - "operationId": "updatePolicyTemplate", + "description": "Update Thumbnail", + "operationId": "updateThumbnail", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of thumbnail", "in": "path", "name": "name", "required": true, @@ -7578,26 +8052,26 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Updated policytemplate" + "description": "Updated thumbnail" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/Thumbnail" } } }, - "description": "Response policytemplates updated just now" + "description": "Response thumbnails updated just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "ThumbnailV1alpha1" ] } }, @@ -8099,6 +8573,12 @@ "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + }, + "thumbnails": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, @@ -9556,6 +10036,145 @@ } } }, + "LocalThumbnail": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/LocalThumbnailSpec" + }, + "status": { + "$ref": "#/components/schemas/LocalThumbnailStatus" + } + } + }, + "LocalThumbnailList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/LocalThumbnail" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "LocalThumbnailSpec": { + "required": [ + "filePath", + "imageSignature", + "imageUri", + "size", + "thumbSignature", + "thumbnailUri" + ], + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "imageSignature": { + "minLength": 1, + "type": "string" + }, + "imageUri": { + "minLength": 1, + "type": "string" + }, + "size": { + "type": "string", + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "thumbSignature": { + "minLength": 1, + "type": "string" + }, + "thumbnailUri": { + "minLength": 1, + "type": "string" + } + } + }, + "LocalThumbnailStatus": { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "PENDING", + "SUCCEEDED", + "FAILED" + ] + } + } + }, "LoginHistory": { "required": [ "loginAt", @@ -12405,6 +13024,120 @@ } } }, + "Thumbnail": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/ThumbnailSpec" + } + } + }, + "ThumbnailList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Thumbnail" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ThumbnailSpec": { + "required": [ + "imageSignature", + "imageUri", + "size", + "thumbnailUri" + ], + "type": "object", + "properties": { + "imageSignature": { + "minLength": 1, + "type": "string" + }, + "imageUri": { + "minLength": 1, + "type": "string" + }, + "size": { + "type": "string", + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "thumbnailUri": { + "minLength": 1, + "type": "string" + } + } + }, "User": { "required": [ "apiVersion", diff --git a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json index 61b7a8896b..7b39276dac 100644 --- a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json @@ -934,6 +934,12 @@ "permalink": { "type": "string", "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + }, + "thumbnails": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, diff --git a/api/build.gradle b/api/build.gradle index 717b853d1a..752acc961a 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -66,6 +66,7 @@ dependencies { api "com.github.java-json-tools:json-patch" api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api 'org.apache.tika:tika-core' + api "org.imgscalr:imgscalr-lib" api "io.github.resilience4j:resilience4j-spring-boot3" api "io.github.resilience4j:resilience4j-reactor" diff --git a/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java b/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java new file mode 100644 index 0000000000..d28a20c669 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java @@ -0,0 +1,40 @@ +package run.halo.app.core.attachment; + +import java.net.URI; +import java.net.URL; +import lombok.Builder; +import lombok.Data; +import org.pf4j.ExtensionPoint; +import reactor.core.publisher.Mono; + +public interface ThumbnailProvider extends ExtensionPoint { + + /** + * Generate thumbnail URI for given image URL and size. + * + * @param context Thumbnail context including image URI and size + * @return Generated thumbnail URI + */ + Mono generate(ThumbnailContext context); + + /** + * Delete thumbnail file for given image URL. + * + * @param imageUrl original image URL + */ + Mono delete(URL imageUrl); + + /** + * Whether the provider supports the given image URI. + * + * @return {@code true} if supports, {@code false} otherwise + */ + Mono supports(ThumbnailContext context); + + @Data + @Builder + class ThumbnailContext { + private final URL imageUrl; + private final ThumbnailSize size; + } +} diff --git a/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java b/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java new file mode 100644 index 0000000000..bdc7035c19 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java @@ -0,0 +1,43 @@ +package run.halo.app.core.attachment; + +import lombok.Getter; + +@Getter +public enum ThumbnailSize { + S(400), + M(800), + L(1200), + XL(1600); + + private final int width; + + ThumbnailSize(int width) { + this.width = width; + } + + /** + * Convert width string to {@link ThumbnailSize}. + * + * @param width width string + */ + public static ThumbnailSize fromWidth(String width) { + for (ThumbnailSize value : values()) { + if (String.valueOf(value.getWidth()).equals(width)) { + return value; + } + } + return ThumbnailSize.M; + } + + /** + * Convert name to {@link ThumbnailSize}. + */ + public static ThumbnailSize fromName(String name) { + for (ThumbnailSize value : values()) { + if (value.name().equalsIgnoreCase(name)) { + return value; + } + } + throw new IllegalArgumentException("No such thumbnail size: " + name); + } +} diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java index 974c97763f..8b37861c9e 100644 --- a/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; @@ -64,5 +65,6 @@ public static class AttachmentStatus { """) private String permalink; + private Map thumbnails; } } diff --git a/application/src/main/java/run/halo/app/content/HtmlThumbnailSrcsetInjector.java b/application/src/main/java/run/halo/app/content/HtmlThumbnailSrcsetInjector.java new file mode 100644 index 0000000000..39f6cedab8 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/HtmlThumbnailSrcsetInjector.java @@ -0,0 +1,92 @@ +package run.halo.app.content; + +import java.net.URI; +import java.util.function.Function; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.core.attachment.ThumbnailSize; + +@UtilityClass +public class HtmlThumbnailSrcsetInjector { + static final String SRC = "src"; + static final String SRCSET = "srcset"; + + /** + * Inject srcset attribute to img tags in the given html. + */ + public static Mono injectSrcset(String html, + Function> srcSetValueGenerator) { + Document document = Jsoup.parseBodyFragment(html); + document.outputSettings(new Document.OutputSettings().prettyPrint(false)); + + Elements imgTags = document.select("img[src]"); + return Flux.fromIterable(imgTags) + .filter(element -> { + String src = element.attr(SRC); + return !element.hasAttr(SRCSET) && isValidSrc(src); + }) + .flatMap(img -> { + String src = img.attr(SRC); + return srcSetValueGenerator.apply(src) + .filter(StringUtils::isNotBlank) + .doOnNext(srcsetValue -> { + img.attr(SRCSET, srcsetValue); + img.attr("sizes", buildSizesAttr()); + }); + }) + .then(Mono.fromSupplier(() -> document.body().html())); + } + + static String buildSizesAttr() { + var sb = new StringBuilder(); + var delimiter = ", "; + var sizes = ThumbnailSize.values(); + for (int i = 0; i < sizes.length; i++) { + var size = sizes[i]; + sb.append("(max-width: ").append(size.getWidth()).append("px)") + .append(" ") + .append(size.getWidth()) + .append("px"); + if (i < sizes.length - 1) { + sb.append(delimiter); + } + } + return sb.toString(); + } + + /** + * Generate srcset attribute value for the given src. + */ + public static Mono generateSrcset(URI src, ThumbnailService thumbnailService) { + return Flux.fromArray(ThumbnailSize.values()) + .flatMap(size -> thumbnailService.generate(src, size) + .map(thumbnail -> thumbnail.toString() + " " + size.getWidth() + "w") + ) + .collect(StringBuilder::new, (builder, srcsetValue) -> { + if (!builder.isEmpty()) { + builder.append(", "); + } + builder.append(srcsetValue); + }) + .map(StringBuilder::toString); + } + + private static boolean isValidSrc(String src) { + if (StringUtils.isBlank(src)) { + return false; + } + try { + URI.create(src); + return true; + } catch (IllegalArgumentException e) { + // ignore + } + return false; + } +} diff --git a/application/src/main/java/run/halo/app/content/PostContentThumbnailHandler.java b/application/src/main/java/run/halo/app/content/PostContentThumbnailHandler.java new file mode 100644 index 0000000000..9a94e4ed46 --- /dev/null +++ b/application/src/main/java/run/halo/app/content/PostContentThumbnailHandler.java @@ -0,0 +1,40 @@ +package run.halo.app.content; + +import static run.halo.app.content.HtmlThumbnailSrcsetInjector.generateSrcset; + +import java.net.URI; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.theme.ReactivePostContentHandler; + +/** + * A post content handler to handle post html content and generate thumbnail by the img tag. + * + * @author guqing + * @since 2.19.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PostContentThumbnailHandler implements ReactivePostContentHandler { + private final ThumbnailService thumbnailService; + + @Override + public Mono handle(@NonNull PostContentContext postContent) { + var html = postContent.getContent(); + return HtmlThumbnailSrcsetInjector.injectSrcset(html, + src -> generateSrcset(URI.create(src), thumbnailService) + ) + .onErrorResume(throwable -> { + log.debug("Failed to inject srcset to post content, fallback to original content", + throwable); + return Mono.just(html); + }) + .doOnNext(postContent::setContent) + .thenReturn(postContent); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/AttachmentRootGetter.java b/application/src/main/java/run/halo/app/core/attachment/AttachmentRootGetter.java new file mode 100644 index 0000000000..3f2e6809f6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/AttachmentRootGetter.java @@ -0,0 +1,10 @@ +package run.halo.app.core.attachment; + +import java.nio.file.Path; +import java.util.function.Supplier; + +/** + * Gets the root path(work dir) of the local attachment. + */ +public interface AttachmentRootGetter extends Supplier { +} diff --git a/application/src/main/java/run/halo/app/core/attachment/AttachmentUtils.java b/application/src/main/java/run/halo/app/core/attachment/AttachmentUtils.java new file mode 100644 index 0000000000..8f95800ed6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/AttachmentUtils.java @@ -0,0 +1,56 @@ +package run.halo.app.core.attachment; + +import static run.halo.app.infra.FileCategoryMatcher.IMAGE; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import lombok.experimental.UtilityClass; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; +import run.halo.app.core.extension.attachment.Attachment; + +@UtilityClass +public class AttachmentUtils { + /** + * Check whether the attachment is an image. + * + * @param attachment Attachment must not be null + * @return true if the attachment is an image, false otherwise + */ + public static boolean isImage(Attachment attachment) { + Assert.notNull(attachment, "Attachment must not be null"); + var mediaType = attachment.getSpec().getMediaType(); + return mediaType != null && IMAGE.match(mediaType); + } + + /** + * Convert URI to URL. + * + * @param uri URI must not be null + * @return URL + * @throws IllegalArgumentException if the URL is malformed + */ + public static URL toUrl(@NonNull URI uri) { + try { + return uri.toURL(); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Encode uri string to URI. + * This method will decode the uri string first and then encode it. + */ + public static URI encodeUri(String uriStr) { + var decodedUriStr = UriUtils.decode(uriStr, StandardCharsets.UTF_8); + return UriComponentsBuilder.fromUriString(decodedUriStr) + .encode(StandardCharsets.UTF_8) + .build() + .toUri(); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java new file mode 100644 index 0000000000..5cd976a971 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java @@ -0,0 +1,43 @@ +package run.halo.app.core.attachment; + +import java.net.URI; +import java.net.URL; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import run.halo.app.infra.ExternalUrlSupplier; + +@Component +@RequiredArgsConstructor +public class LocalThumbnailProvider implements ThumbnailProvider { + private final ExternalUrlSupplier externalUrlSupplier; + private final LocalThumbnailService localThumbnailService; + + @Override + public Mono generate(ThumbnailContext context) { + return localThumbnailService.create(context.getImageUrl(), context.getSize()) + .map(localThumbnail -> localThumbnail.getSpec().getThumbnailUri()) + .map(URI::create); + } + + @Override + public Mono delete(URL imageUrl) { + Assert.notNull(imageUrl, "Image URL must not be null"); + return localThumbnailService.delete(URI.create(imageUrl.toString())); + } + + @Override + public Mono supports(ThumbnailContext context) { + var imageUrl = context.getImageUrl(); + var externalUrl = externalUrlSupplier.getRaw(); + return Mono.fromSupplier(() -> externalUrl != null + && isSameOrigin(imageUrl, externalUrl)); + } + + private boolean isSameOrigin(URL imageUrl, URL externalUrl) { + return StringUtils.equals(imageUrl.getHost(), externalUrl.getHost()) + && imageUrl.getPort() == externalUrl.getPort(); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java new file mode 100644 index 0000000000..0e2f81e085 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java @@ -0,0 +1,81 @@ +package run.halo.app.core.attachment; + +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import org.springframework.core.io.Resource; +import org.springframework.lang.NonNull; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.exception.NotFoundException; + +public interface LocalThumbnailService { + + /** + * Gets original image URI for the given thumbnail URI. + * + * @param thumbnailUri The thumbnail URI string + * @return The original image URI, {@link NotFoundException} will be thrown if the thumbnail + * record does not exist by the given thumbnail URI + */ + Mono getOriginalImageUri(String thumbnailUri); + + /** + *

Gets thumbnail file resource for the given year, size and filename.

+ * {@link Mono#empty()} will be returned if the thumbnail file does not generate yet or the + * thumbnail record does not exist. + * + * @param thumbnailUri The thumbnail URI string + * @return The thumbnail file resource + */ + Mono getThumbnail(String thumbnailUri); + + /** + *

Gets thumbnail file resource for the given URI and size.

+ * {@link Mono#empty()} will be returned if the thumbnail file does not generate yet. + * + * @param originalImageUri original image URI to get thumbnail + * @param size thumbnail size + */ + Mono getThumbnail(URI originalImageUri, ThumbnailSize size); + + /** + * Generate thumbnail file for the given thumbnail. + * Do nothing if the thumbnail file already exists. + * + * @param thumbnail The thumbnail to generate. + * @return The generated thumbnail file resource. + */ + Mono generate(LocalThumbnail thumbnail); + + /** + * Creates a {@link LocalThumbnail} record for the given image URL and size. + * The thumbnail file will be generated asynchronously according to the thumbnail record. + * + * @param imageUrl original image URL + * @param size thumbnail size to generate + * @return The created thumbnail record. + */ + Mono create(URL imageUrl, ThumbnailSize size); + + /** + * Deletes the all size thumbnail files for the given image URI. + * If the image URI is not absolute, it will be processed by {@link ExternalLinkProcessor}. + * + * @param imageUri original image URI to delete thumbnails + * @return A {@link Mono} indicates the completion of the deletion. + */ + Mono delete(URI imageUri); + + /** + * Ensures the image URI is an url path if it's an in-site image. + * If it's not an in-site image, it will return directly. + */ + @NonNull + URI ensureInSiteUriIsRelative(URI imageUri); + + Path toFilePath(String thumbRelativeUnixPath); + + String buildThumbnailUri(String year, ThumbnailSize size, String filename); +} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java new file mode 100644 index 0000000000..dc2ec2f021 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java @@ -0,0 +1,173 @@ +package run.halo.app.core.attachment; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.Optional; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.imgscalr.Scalr; + +@Slf4j +@AllArgsConstructor +public class ThumbnailGenerator { + /** + * Max file size in bytes for downloading. + * 30MB + */ + static final int MAX_FILE_SIZE = 30 * 1024 * 1024; + + private final ImageDownloader imageDownloader = new ImageDownloader(); + private final ThumbnailSize size; + private final Path storePath; + + /** + * Generate thumbnail and save it to store path. + */ + public void generate(URL imageUrl) { + Path tempImagePath = null; + try { + tempImagePath = imageDownloader.downloadFile(imageUrl); + generateThumbnail(tempImagePath); + } catch (IOException e) { + throw new IllegalStateException(e); + } finally { + if (tempImagePath != null) { + try { + Files.deleteIfExists(tempImagePath); + } catch (IOException e) { + // Ignore + } + } + } + } + + /** + * Generate thumbnail by image file path. + */ + public void generate(Path imageFilePath) { + try { + generateThumbnail(imageFilePath); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private void generateThumbnail(Path tempImagePath) throws IOException { + var file = tempImagePath.toFile(); + if (file.length() > MAX_FILE_SIZE) { + throw new IOException("File size exceeds the limit: " + MAX_FILE_SIZE); + } + var formatNameOpt = getFormatName(file); + var img = ImageIO.read(file); + if (img == null) { + throw new UnsupportedOperationException( + "Unsupported image format for: " + formatNameOpt.orElse("unknown")); + } + var thumbnail = Scalr.resize(img, Scalr.Method.SPEED, Scalr.Mode.FIT_TO_WIDTH, + size.getWidth()); + var formatName = formatNameOpt.orElse("jpg"); + var thumbnailFile = getThumbnailFile(formatName); + ImageIO.write(thumbnail, formatName, thumbnailFile); + } + + private File getThumbnailFile(String formatName) { + return Optional.of(storePath) + .map(path -> { + if (storePath.endsWith(formatName)) { + return storePath.resolve("." + formatName); + } + return storePath; + }) + .map(path -> { + if (!Files.exists(path.getParent())) { + try { + Files.createDirectories(path.getParent()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return path; + }) + .map(Path::toFile) + .orElseThrow(); + } + + /** + * Sanitize file name. + * + * @param fileName file name to sanitize + */ + public static String sanitizeFileName(String fileName) { + String sanitizedFileName = fileName.replaceAll("[^a-zA-Z0-9\\.\\-]", ""); + + if (sanitizedFileName.length() > 255) { + sanitizedFileName = sanitizedFileName.substring(0, 255); + } + + return sanitizedFileName; + } + + private static Optional getFormatName(File file) { + try { + return Optional.of(doGetFormatName(file)); + } catch (IOException e) { + // Ignore + } + return Optional.empty(); + } + + private static String doGetFormatName(File file) throws IOException { + try (ImageInputStream imageStream = ImageIO.createImageInputStream(file)) { + Iterator readers = ImageIO.getImageReaders(imageStream); + if (!readers.hasNext()) { + throw new IOException("No ImageReader found for the image."); + } + ImageReader reader = readers.next(); + return reader.getFormatName().toLowerCase(); + } + } + + static class ImageDownloader { + public Path downloadFile(URL url) throws IOException { + var encodedUri = AttachmentUtils.encodeUri(url.toString()); + return downloadFileInternal(encodedUri.toURL()); + } + + Path downloadFileInternal(URL url) throws IOException { + File tempFile = File.createTempFile("halo-image-thumb-", ".tmp"); + long totalBytesDownloaded = 0; + var tempFilePath = tempFile.toPath(); + try (InputStream inputStream = url.openStream(); + FileOutputStream outputStream = new FileOutputStream(tempFile)) { + + byte[] buffer = new byte[4096]; + int bytesRead; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + totalBytesDownloaded += bytesRead; + + if (totalBytesDownloaded > MAX_FILE_SIZE) { + outputStream.close(); + Files.deleteIfExists(tempFilePath); + throw new IOException("File size exceeds the limit: " + MAX_FILE_SIZE); + } + + outputStream.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + Files.deleteIfExists(tempFilePath); + throw e; + } + return tempFile.toPath(); + } + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java new file mode 100644 index 0000000000..dfa1680008 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailService.java @@ -0,0 +1,22 @@ +package run.halo.app.core.attachment; + +import java.net.URI; +import reactor.core.publisher.Mono; +import run.halo.app.infra.ExternalLinkProcessor; + +public interface ThumbnailService { + + /** + * Generate thumbnail by the given image uri and size. + *

if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor} + * .

+ *

if externalUrl is not configured, it will return empty.

+ * + * @param imageUri image uri to generate thumbnail + * @param size thumbnail size to generate + * @return generated thumbnail uri if success, otherwise empty. + */ + Mono generate(URI imageUri, ThumbnailSize size); + + Mono delete(URI imageUri); +} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailSigner.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailSigner.java new file mode 100644 index 0000000000..c9d335e8e9 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailSigner.java @@ -0,0 +1,37 @@ +package run.halo.app.core.attachment; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ThumbnailSigner { + private static final String ALGORITHM = "SHA-256"; + + /** + * Generate signature for the given input. + * + * @param input generally the uri of the thumbnail + */ + public static String generateSignature(String input) { + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + BigInteger number = new BigInteger(1, hashBytes); + StringBuilder hexString = new StringBuilder(number.toString(16)); + + // Complete the string to ensure a length of 64 characters + while (hexString.length() < 64) { + hexString.insert(0, '0'); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(ALGORITHM + " algorithm not found", e); + } + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImpl.java new file mode 100644 index 0000000000..6acda4bcdc --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImpl.java @@ -0,0 +1,18 @@ +package run.halo.app.core.attachment.impl; + +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.infra.properties.HaloProperties; + +@Component +@RequiredArgsConstructor +public class AttachmentRootGetterImpl implements AttachmentRootGetter { + private final HaloProperties haloProp; + + @Override + public Path get() { + return haloProp.getWorkDir().resolve("attachments"); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java new file mode 100644 index 0000000000..65b231a391 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java @@ -0,0 +1,284 @@ +package run.halo.app.core.attachment.impl; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; +import static org.apache.commons.lang3.StringUtils.defaultIfBlank; +import static org.apache.commons.lang3.StringUtils.removeStart; +import static org.apache.commons.lang3.StringUtils.substringAfterLast; +import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +import java.net.URI; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Sort; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.core.attachment.LocalThumbnailService; +import run.halo.app.core.attachment.ThumbnailGenerator; +import run.halo.app.core.attachment.ThumbnailSigner; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalUrlSupplier; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalThumbnailServiceImpl implements LocalThumbnailService { + private final AttachmentRootGetter attachmentDirGetter; + private final ReactiveExtensionClient client; + private final ExternalUrlSupplier externalUrlSupplier; + + private static Path buildThumbnailStorePath(Path rootPath, String fileName, String year, + ThumbnailSize size) { + return rootPath + .resolve("thumbnails") + .resolve(year) + .resolve("w" + size.getWidth()) + .resolve(fileName); + } + + static String geImageFileName(URL imageUrl) { + var fileName = substringAfterLast(imageUrl.getPath(), "/"); + fileName = defaultIfBlank(fileName, randomAlphanumeric(10)); + return ThumbnailGenerator.sanitizeFileName(fileName); + } + + static String getYear() { + return String.valueOf(LocalDateTime.now().getYear()); + } + + @Override + public Mono getOriginalImageUri(String thumbnailUri) { + return fetchThumbnail(thumbnailUri) + .map(local -> URI.create(local.getSpec().getImageUri())); + } + + @Override + public Mono getThumbnail(String thumbnailUri) { + Assert.notNull(thumbnailUri, "Thumbnail URI must not be null."); + return fetchThumbnail(thumbnailUri) + .flatMap(thumbnail -> { + var filePath = toFilePath(thumbnail.getSpec().getFilePath()); + if (Files.exists(filePath)) { + return getResourceMono(() -> new FileSystemResource(filePath)); + } + return generate(thumbnail) + .then(Mono.empty()); + }); + } + + @Override + public Mono getThumbnail(URI originalImageUri, ThumbnailSize size) { + var imageHash = signatureForImageUri(originalImageUri); + return fetchByImageHashAndSize(imageHash, size) + .flatMap(this::generate); + } + + private Mono fetchThumbnail(String thumbnailUri) { + Assert.notNull(thumbnailUri, "Thumbnail URI must not be null."); + var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri); + return client.listBy(LocalThumbnail.class, ListOptions.builder() + .fieldQuery(equal("spec.thumbSignature", thumbSignature)) + .build(), PageRequestImpl.ofSize(1)) + .flatMap(result -> Mono.justOrEmpty(ListResult.first(result))); + } + + private Mono fetchByImageHashAndSize(String imageSignature, + ThumbnailSize size) { + return client.listBy(LocalThumbnail.class, ListOptions.builder() + .fieldQuery(equal("spec.imageSignature", imageSignature)) + .build(), PageRequestImpl.ofSize(ThumbnailSize.values().length)) + .flatMapMany(result -> Flux.fromIterable(result.getItems())) + .filter(thumbnail -> thumbnail.getSpec().getSize().equals(size)) + .next(); + } + + @Override + public Mono generate(LocalThumbnail thumbnail) { + Assert.notNull(thumbnail, "Thumbnail must not be null."); + var filePath = toFilePath(thumbnail.getSpec().getFilePath()); + if (Files.exists(filePath)) { + return getResourceMono(() -> new FileSystemResource(filePath)); + } + return updateWithRetry(thumbnail, + record -> nullSafeAnnotations(record) + .put(LocalThumbnail.REQUEST_TO_GENERATE_ANNO, "true")) + .then(Mono.empty()); + } + + private static Mono getResourceMono(Callable callable) { + return Mono.fromCallable(callable) + .subscribeOn(Schedulers.boundedElastic()); + } + + private Mono updateWithRetry(LocalThumbnail localThumbnail, Consumer op) { + op.accept(localThumbnail); + return client.update(localThumbnail) + .onErrorResume(OptimisticLockingFailureException.class, + e -> client.fetch(LocalThumbnail.class, localThumbnail.getMetadata().getName()) + .flatMap(latest -> { + op.accept(latest); + return client.update(latest); + }) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + ) + .then(); + } + + @Override + public Mono create(URL imageUrl, ThumbnailSize size) { + Assert.notNull(imageUrl, "Image URL must not be null."); + Assert.notNull(size, "Thumbnail size must not be null."); + var year = getYear(); + var originalFileName = geImageFileName(imageUrl); + var imageUri = URI.create(imageUrl.toString()); + return generateUniqueThumbFileName(originalFileName, year, size) + .flatMap(thumbFileName -> { + var filePath = + buildThumbnailStorePath(attachmentDirGetter.get(), thumbFileName, year, size); + var thumbnail = new LocalThumbnail(); + thumbnail.setMetadata(new Metadata()); + thumbnail.getMetadata().setGenerateName("thumbnail-"); + var thumbnailUri = buildThumbnailUri(year, size, thumbFileName); + var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri); + thumbnail.setSpec(new LocalThumbnail.Spec() + .setImageSignature(signatureForImageUri(imageUri)) + .setFilePath(toRelativeUnixPath(filePath)) + .setImageUri(ensureInSiteUriIsRelative(imageUri).toString()) + .setSize(size) + .setThumbSignature(thumbSignature) + .setThumbnailUri(thumbnailUri)); + return client.create(thumbnail); + }); + } + + @Override + public Mono delete(URI imageUri) { + var signature = signatureForImageUri(imageUri); + return client.listAll(LocalThumbnail.class, ListOptions.builder() + .fieldQuery(and( + equal("spec.imageSignature", signature), + isNull("metadata.deletionTimestamp")) + ) + .build(), Sort.unsorted()) + .flatMap(thumbnail -> { + var filePath = toFilePath(thumbnail.getSpec().getFilePath()); + return deleteFile(filePath) + .then(client.delete(thumbnail)); + }) + .then(); + } + + @Override + @NonNull + public URI ensureInSiteUriIsRelative(URI imageUri) { + Assert.notNull(imageUri, "Image URI must not be null."); + var externalUrl = externalUrlSupplier.getRaw(); + if (externalUrl == null || !isSameOrigin(imageUri, externalUrl)) { + return imageUri; + } + var uriStr = imageUri.toString().replaceFirst("^\\w+://", ""); + uriStr = StringUtils.removeStart(uriStr, imageUri.getAuthority()); + return URI.create(uriStr); + } + + Mono generateUniqueThumbFileName(String originalFileName, String year, + ThumbnailSize size) { + Assert.notNull(originalFileName, "Original file name must not be null."); + return generateUniqueThumbFileName(originalFileName, originalFileName, year, size); + } + + private Mono generateUniqueThumbFileName(String originalFileName, String tryFileName, + String year, ThumbnailSize size) { + var thumbnailUri = buildThumbnailUri(year, size, tryFileName); + return fetchThumbnail(thumbnailUri) + .flatMap(thumbnail -> { + // use the original file name to generate a new file name + var newTryFileName = appendRandomSuffix(originalFileName); + return generateUniqueThumbFileName(originalFileName, newTryFileName, year, size); + }) + .switchIfEmpty(Mono.just(tryFileName)); + } + + @Override + public Path toFilePath(String relativeUnixPath) { + Assert.notNull(relativeUnixPath, "Relative path must not be null."); + var systemPath = removeStart(relativeUnixPath, "/") + .replace("/", FileSystems.getDefault().getSeparator()); + return attachmentDirGetter.get().resolve(systemPath); + } + + @Override + public String buildThumbnailUri(String year, ThumbnailSize size, String filename) { + return "/upload/thumbnails/%s/w%s/%s".formatted(year, size.getWidth(), filename); + } + + private String toRelativeUnixPath(Path filePath) { + var dir = attachmentDirGetter.get().toString(); + var relativePath = removeStart(filePath.toString(), dir); + return relativePath.replace("\\", "/"); + } + + private Mono deleteFile(Path path) { + return Mono.fromRunnable( + () -> { + try { + Files.deleteIfExists(path); + } catch (Exception e) { + throw Exceptions.propagate(e); + } + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + /** + * Generate signature for the given image URI. + *

if externalUrl is not configured, it will return the signature generated by the image URI + * directly, otherwise, it will return the signature generated by the relative path of the + * image URL to the external URL.

+ */ + String signatureForImageUri(URI imageUri) { + var uriToSign = ensureInSiteUriIsRelative(imageUri).toString(); + return ThumbnailSigner.generateSignature(uriToSign); + } + + private boolean isSameOrigin(URI imageUri, URL externalUrl) { + return StringUtils.equals(imageUri.getHost(), externalUrl.getHost()) + && imageUri.getPort() == externalUrl.getPort(); + } + + static String appendRandomSuffix(String fileName) { + var baseName = StringUtils.substringBeforeLast(fileName, "."); + var extension = substringAfterLast(fileName, "."); + var randomSuffix = randomAlphanumeric(6); + return String.format("%s_%s.%s", baseName, randomSuffix, extension); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java new file mode 100644 index 0000000000..fb65d99ba1 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java @@ -0,0 +1,133 @@ +package run.halo.app.core.attachment.impl; + +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentUtils; +import run.halo.app.core.attachment.LocalThumbnailService; +import run.halo.app.core.attachment.ThumbnailProvider; +import run.halo.app.core.attachment.ThumbnailProvider.ThumbnailContext; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.core.attachment.ThumbnailSigner; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.attachment.Thumbnail; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ThumbnailServiceImpl implements ThumbnailService { + private final ExtensionGetter extensionGetter; + private final ReactiveExtensionClient client; + private final ExternalLinkProcessor externalLinkProcessor; + private final ThumbnailProvider thumbnailProvider; + private final LocalThumbnailService localThumbnailService; + + @Override + public Mono generate(URI imageUri, ThumbnailSize size) { + var imageUrlOpt = toImageUrl(imageUri); + if (imageUrlOpt.isEmpty()) { + return Mono.empty(); + } + var imageUrl = imageUrlOpt.get(); + return fetchThumbnail(imageUri, size) + .map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri())) + .switchIfEmpty(create(imageUrl, size)) + .onErrorResume(Throwable.class, e -> { + log.warn("Failed to generate thumbnail for image: {}", imageUrl, e); + return Mono.just(URI.create(imageUrl.toString())); + }); + } + + @Override + public Mono delete(URI imageUri) { + Assert.notNull(imageUri, "Image uri must not be null"); + Mono deleteMono; + if (imageUri.isAbsolute()) { + deleteMono = thumbnailProvider.delete(AttachmentUtils.toUrl(imageUri)); + } else { + // Local thumbnails maybe a relative path, so we need to process it. + deleteMono = localThumbnailService.delete(imageUri); + } + return deleteMono.then(deleteThumbnailRecord(imageUri)); + } + + private Mono deleteThumbnailRecord(URI imageUri) { + var imageHash = signatureFor(imageUri); + var listOptions = ListOptions.builder() + .fieldQuery(startsWith(Thumbnail.ID_INDEX, Thumbnail.idIndexFunc(imageHash, ""))) + .build(); + return client.listAll(Thumbnail.class, listOptions, Sort.unsorted()) + .flatMap(client::delete) + .then(); + } + + Optional toImageUrl(URI imageUri) { + try { + if (imageUri.isAbsolute()) { + return Optional.of(imageUri.toURL()); + } + var url = new URL(externalLinkProcessor.processLink(imageUri.toString())); + return Optional.of(url); + } catch (MalformedURLException e) { + // Ignore + } + return Optional.empty(); + } + + Mono create(URL imageUrl, ThumbnailSize size) { + var context = ThumbnailContext.builder() + .imageUrl(imageUrl) + .size(size) + .build(); + var imageUri = + localThumbnailService.ensureInSiteUriIsRelative(URI.create(imageUrl.toString())); + return extensionGetter.getEnabledExtensions(ThumbnailProvider.class) + .filterWhen(provider -> provider.supports(context)) + .next() + .flatMap(provider -> provider.generate(context)) + .flatMap(uri -> { + var thumb = new Thumbnail(); + thumb.setMetadata(new Metadata()); + thumb.getMetadata().setGenerateName("thumb-"); + thumb.setSpec(new Thumbnail.Spec() + .setSize(size) + .setThumbnailUri(uri.toString()) + .setImageUri(imageUri.toString()) + .setImageSignature(signatureFor(imageUri)) + ); + return client.create(thumb) + .thenReturn(uri); + }); + } + + private String signatureFor(URI imageUri) { + var uri = localThumbnailService.ensureInSiteUriIsRelative(imageUri); + return ThumbnailSigner.generateSignature(uri.toString()); + } + + Mono fetchThumbnail(URI imageUri, ThumbnailSize size) { + var imageHash = signatureFor(imageUri); + var id = Thumbnail.idIndexFunc(imageHash, size.name()); + return client.listBy(Thumbnail.class, ListOptions.builder() + .fieldQuery(equal(Thumbnail.ID_INDEX, id)) + .build(), PageRequestImpl.ofSize(1)) + .flatMap(result -> Mono.justOrEmpty(ListResult.first(result))); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java similarity index 51% rename from application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java rename to application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java index d525e2ffa2..0441390cc5 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java @@ -1,50 +1,60 @@ -package run.halo.app.core.extension.reconciler.attachment; +package run.halo.app.core.attachment.reconciler; + +import static run.halo.app.extension.ExtensionUtil.addFinalizers; +import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import java.net.URI; -import java.util.HashSet; +import java.time.Duration; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentUtils; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus; import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; -import run.halo.app.infra.ExternalUrlSupplier; @Slf4j @Component +@RequiredArgsConstructor public class AttachmentReconciler implements Reconciler { private final ExtensionClient client; - private final ExternalUrlSupplier externalUrl; - private final AttachmentService attachmentService; - public AttachmentReconciler(ExtensionClient client, - ExternalUrlSupplier externalUrl, AttachmentService attachmentService) { - this.client = client; - this.externalUrl = externalUrl; - this.attachmentService = attachmentService; - } + private final ThumbnailService thumbnailService; @Override public Result reconcile(Request request) { client.fetch(Attachment.class, request.name()).ifPresent(attachment -> { - // TODO Handle the finalizer - if (attachment.getMetadata().getDeletionTimestamp() != null) { - removeFinalizer(attachment); + if (ExtensionUtil.isDeleted(attachment)) { + if (removeFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME))) { + cleanUpResources(attachment); + client.update(attachment); + } return; } // add finalizer - addFinalizerIfNotSet(request.name(), attachment.getMetadata().getFinalizers()); + if (addFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME))) { + client.update(attachment); + } + var annotations = attachment.getMetadata().getAnnotations(); if (annotations != null) { attachmentService.getPermalink(attachment) @@ -55,20 +65,29 @@ public Result reconcile(Request request) { })) .doOnNext(permalink -> { log.debug("Set permalink {} for attachment {}", permalink, request.name()); - var status = attachment.getStatus(); - if (status == null) { - status = new AttachmentStatus(); - attachment.setStatus(status); - } + var status = nullSafeStatus(attachment); status.setPermalink(permalink); }) .blockOptional(); } + var permalink = nullSafeStatus(attachment).getPermalink(); + if (StringUtils.isNotBlank(permalink) && AttachmentUtils.isImage(attachment)) { + populateThumbnails(permalink, attachment.getStatus()); + } updateStatus(request.name(), attachment.getStatus()); }); return null; } + private static AttachmentStatus nullSafeStatus(Attachment attachment) { + var status = attachment.getStatus(); + if (status == null) { + status = new AttachmentStatus(); + attachment.setStatus(status); + } + return status; + } + @Override public Controller setupWith(ControllerBuilder builder) { return builder @@ -76,6 +95,17 @@ public Controller setupWith(ControllerBuilder builder) { .build(); } + void populateThumbnails(String permalink, AttachmentStatus status) { + var imageUri = URI.create(permalink); + Flux.fromArray(ThumbnailSize.values()) + .flatMap(size -> thumbnailService.generate(imageUri, size) + .map(thumbUri -> Map.entry(size.name(), thumbUri.toString())) + ) + .collectMap(Map.Entry::getKey, Map.Entry::getValue) + .doOnNext(status::setThumbnails) + .block(); + } + void updateStatus(String attachmentName, AttachmentStatus status) { client.fetch(Attachment.class, attachmentName) .filter(attachment -> !Objects.deepEquals(attachment.getStatus(), status)) @@ -85,41 +115,13 @@ void updateStatus(String attachmentName, AttachmentStatus status) { }); } - void removeFinalizer(Attachment oldAttachment) { - if (!hasFinalizer(oldAttachment, Constant.FINALIZER_NAME)) { - return; - } - attachmentService.delete(oldAttachment).block(); - client.fetch(Attachment.class, oldAttachment.getMetadata().getName()) - .ifPresent(attachment -> { - var finalizers = attachment.getMetadata().getFinalizers(); - if (hasFinalizer(attachment, Constant.FINALIZER_NAME) - && finalizers.remove(Constant.FINALIZER_NAME)) { - // update it - client.update(attachment); - } - }); - } + void cleanUpResources(Attachment attachment) { + var timeout = Duration.ofSeconds(20); + Optional.ofNullable(attachment.getStatus()) + .map(AttachmentStatus::getPermalink) + .map(URI::create) + .ifPresent(uri -> thumbnailService.delete(uri).block(timeout)); - boolean hasFinalizer(Attachment attachment, String finalizer) { - var finalizers = attachment.getMetadata().getFinalizers(); - return finalizers != null && finalizers.contains(finalizer); + attachmentService.delete(attachment).block(timeout); } - - void addFinalizerIfNotSet(String attachmentName, Set existingFinalizers) { - if (existingFinalizers != null && existingFinalizers.contains(Constant.FINALIZER_NAME)) { - return; - } - - client.fetch(Attachment.class, attachmentName).ifPresent(attachment -> { - var finalizers = attachment.getMetadata().getFinalizers(); - if (finalizers == null) { - finalizers = new HashSet<>(); - attachment.getMetadata().setFinalizers(finalizers); - } - finalizers.add(Constant.FINALIZER_NAME); - client.update(attachment); - }); - } - } diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java new file mode 100644 index 0000000000..1f1eb7b38f --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java @@ -0,0 +1,184 @@ +package run.halo.app.core.attachment.reconciler; + +import static org.springframework.data.domain.Sort.Order.desc; +import static run.halo.app.core.extension.attachment.LocalThumbnail.REQUEST_TO_GENERATE_ANNO; +import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.core.attachment.AttachmentUtils; +import run.halo.app.core.attachment.LocalThumbnailService; +import run.halo.app.core.attachment.ThumbnailGenerator; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Constant; +import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.infra.ExternalLinkProcessor; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalThumbnailsReconciler implements Reconciler { + private final LocalThumbnailService localThumbnailService; + private final ExtensionClient client; + private final ExternalLinkProcessor externalLinkProcessor; + private final AttachmentRootGetter attachmentRootGetter; + + @Override + public Result reconcile(Request request) { + client.fetch(LocalThumbnail.class, request.name()) + .ifPresent(thumbnail -> { + if (shouldGenerate(thumbnail)) { + requestGenerateThumbnail(thumbnail); + nullSafeAnnotations(thumbnail).remove(REQUEST_TO_GENERATE_ANNO); + client.update(thumbnail); + } + }); + return Result.doNotRetry(); + } + + private boolean shouldGenerate(LocalThumbnail thumbnail) { + var annotations = nullSafeAnnotations(thumbnail); + return annotations.containsKey(REQUEST_TO_GENERATE_ANNO) + || thumbnailFileNotExists(thumbnail); + } + + private boolean thumbnailFileNotExists(LocalThumbnail thumbnail) { + var filePath = localThumbnailService.toFilePath(thumbnail.getSpec().getFilePath()); + return !Files.exists(filePath); + } + + void requestGenerateThumbnail(LocalThumbnail thumbnail) { + // If the thumbnail generation has failed, we should not retry it + if (isGenerationFailed(thumbnail)) { + return; + } + var imageUri = thumbnail.getSpec().getImageUri(); + var filePath = localThumbnailService.toFilePath(thumbnail.getSpec().getFilePath()); + if (Files.exists(filePath)) { + return; + } + var generator = new ThumbnailGenerator(thumbnail.getSpec().getSize(), filePath); + var imageUrlOpt = toImageUrl(imageUri); + if (imageUrlOpt.isEmpty()) { + if (tryGenerateByAttachment(imageUri, generator)) { + thumbnail.getStatus().setPhase(LocalThumbnail.Phase.SUCCEEDED); + } else { + log.debug("Failed to parse image URL,please check external-url configuration for " + + "record: {}", thumbnail.getMetadata().getName()); + thumbnail.getStatus().setPhase(LocalThumbnail.Phase.FAILED); + } + return; + } + var imageUrl = imageUrlOpt.get(); + if (generateThumbnail(thumbnail, imageUrl, generator)) { + thumbnail.getStatus().setPhase(LocalThumbnail.Phase.SUCCEEDED); + } else { + thumbnail.getStatus().setPhase(LocalThumbnail.Phase.FAILED); + } + } + + private boolean isGenerationFailed(LocalThumbnail thumbnail) { + return LocalThumbnail.Phase.FAILED.equals(thumbnail.getStatus().getPhase()); + } + + private boolean generateThumbnail(LocalThumbnail thumbnail, URL imageUrl, + ThumbnailGenerator generator) { + return tryGenerateByAttachment(thumbnail.getSpec().getImageUri(), generator) + || tryGenerateByUrl(imageUrl, generator); + } + + private boolean tryGenerate(String resourceIdentifier, Runnable generateAction) { + try { + generateAction.run(); + return true; + } catch (Throwable e) { + log.debug("Failed to generate thumbnail for: {}", resourceIdentifier, e); + return false; + } + } + + private boolean tryGenerateByUrl(URL imageUrl, ThumbnailGenerator generator) { + return tryGenerate(imageUrl.toString(), () -> { + log.debug("Generating thumbnail for image URL: {}", imageUrl); + generator.generate(imageUrl); + }); + } + + private boolean tryGenerateByAttachment(String imageUri, ThumbnailGenerator generator) { + return fetchAttachmentFilePath(imageUri) + .map(path -> tryGenerate(imageUri, () -> { + log.debug("Generating thumbnail for attachment file path: {}", path); + generator.generate(path); + })) + .orElse(false); + } + + Optional toImageUrl(String imageUriStr) { + var imageUri = URI.create(imageUriStr); + try { + var url = new URL(externalLinkProcessor.processLink(imageUri.toString())); + return Optional.of(url); + } catch (MalformedURLException e) { + // Ignore + } + return Optional.empty(); + } + + Optional fetchAttachmentFilePath(String imageUri) { + return fetchAttachmentByPermalink(imageUri) + .filter(AttachmentUtils::isImage) + .map(attachment -> { + var annotations = nullSafeAnnotations(attachment); + var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY); + if (StringUtils.isBlank(localRelativePath)) { + return null; + } + var attachmentsRoot = attachmentRootGetter.get(); + var filePath = attachmentsRoot.resolve(localRelativePath); + checkDirectoryTraversal(attachmentsRoot, filePath); + return filePath; + }); + } + + Optional fetchAttachmentByPermalink(String permalink) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("status.permalink", permalink), + isNull("metadata.deletionTimestamp") + )) + .build(); + var pageRequest = PageRequestImpl.ofSize(1) + .withSort(Sort.by(desc("metadata.creationTimestamp"))); + return client.listBy(Attachment.class, listOptions, pageRequest) + .get() + .findFirst(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new LocalThumbnail()) + .syncAllOnStart(true) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java b/application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java new file mode 100644 index 0000000000..652ffe2f99 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java @@ -0,0 +1,88 @@ +package run.halo.app.core.extension.attachment; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.NonNull; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.core.attachment.ThumbnailSigner; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +@Data +@EqualsAndHashCode(callSuper = true) +@GVK(group = "storage.halo.run", version = "v1alpha1", kind = "LocalThumbnail", + plural = "localthumbnails", singular = "localthumbnail") +public class LocalThumbnail extends AbstractExtension { + public static final String REQUEST_TO_GENERATE_ANNO = "storage.halo.run/request-to-generate"; + + @Schema(requiredMode = REQUIRED) + private Spec spec; + + @Getter(onMethod_ = @NonNull) + @Schema(requiredMode = NOT_REQUIRED) + private Status status = new Status(); + + public void setStatus(Status status) { + this.status = (status == null ? new Status() : status); + } + + public static String signatureFor(String imageUri) { + return ThumbnailSigner.generateSignature(imageUri); + } + + @Data + @Accessors(chain = true) + @Schema(name = "LocalThumbnailSpec") + public static class Spec { + /** + * A hash signature for the image uri. + * + * @see #getImageUri() + */ + @Schema(requiredMode = REQUIRED, minLength = 1) + private String imageSignature; + + @Schema(requiredMode = REQUIRED, minLength = 1) + private String imageUri; + + @Schema(requiredMode = REQUIRED, minLength = 1) + private String thumbnailUri; + + /** + * A hash signature for the thumbnail uri. + * + * @see #getThumbnailUri() + */ + @Schema(requiredMode = REQUIRED, minLength = 1) + private String thumbSignature; + + @Schema(requiredMode = REQUIRED) + private ThumbnailSize size; + + /** + * Consider the compatibility of the system and migration, use unix-style relative paths + * here. + * + * @see AttachmentRootGetter + */ + @Schema(requiredMode = REQUIRED) + private String filePath; + } + + @Data + @Schema(name = "LocalThumbnailStatus") + public static class Status { + private Phase phase; + } + + public enum Phase { + PENDING, SUCCEEDED, FAILED + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java b/application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java new file mode 100644 index 0000000000..759a362f52 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java @@ -0,0 +1,49 @@ +package run.halo.app.core.extension.attachment; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +@Data +@EqualsAndHashCode(callSuper = true) +@GVK(group = "storage.halo.run", version = "v1alpha1", kind = "Thumbnail", + plural = "thumbnails", singular = "thumbnail") +public class Thumbnail extends AbstractExtension { + + public static final String ID_INDEX = "thumbnail-id"; + + @Schema(requiredMode = REQUIRED) + private Spec spec; + + @Data + @Accessors(chain = true) + @Schema(name = "ThumbnailSpec") + public static class Spec { + @Schema(requiredMode = REQUIRED, minLength = 1) + private String imageSignature; + + @Schema(requiredMode = REQUIRED, minLength = 1) + private String imageUri; + + @Schema(requiredMode = REQUIRED) + private ThumbnailSize size; + + @Schema(requiredMode = REQUIRED, minLength = 1) + private String thumbnailUri; + } + + public static String idIndexFunc(Thumbnail thumbnail) { + return idIndexFunc(thumbnail.getSpec().getImageSignature(), + thumbnail.getSpec().getSize().name()); + } + + public static String idIndexFunc(String imageHash, String size) { + return imageHash + "-" + size; + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java index 792b942362..0d75c422a1 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; import org.springdoc.core.fn.builders.operation.Builder; @@ -63,18 +64,13 @@ @Slf4j @Component +@RequiredArgsConstructor public class AttachmentEndpoint implements CustomEndpoint { private final AttachmentService attachmentService; private final ReactiveExtensionClient client; - public AttachmentEndpoint(AttachmentService attachmentService, - ReactiveExtensionClient client) { - this.attachmentService = attachmentService; - this.client = client; - } - @Override public RouterFunction endpoint() { var tag = "AttachmentV1alpha1Console"; @@ -189,7 +185,7 @@ public interface ISearchRequest extends IListRequest { example = "creationTimestamp,desc")) Sort getSort(); - public static void buildParameters(Builder builder) { + static void buildParameters(Builder builder) { IListRequest.buildParameters(builder); builder.parameter(QueryParamBuildUtil.sortParameter()) .parameter(parameterBuilder() diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java index 60ef6c9c50..b0db21fc81 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java +++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java @@ -36,6 +36,7 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; +import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Constant; @@ -47,7 +48,6 @@ import run.halo.app.infra.exception.AttachmentAlreadyExistsException; import run.halo.app.infra.exception.FileSizeExceededException; import run.halo.app.infra.exception.FileTypeNotAllowedException; -import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.utils.FileTypeDetectUtils; import run.halo.app.infra.utils.JsonUtils; @@ -55,20 +55,16 @@ @Component class LocalAttachmentUploadHandler implements AttachmentHandler { - private final HaloProperties haloProp; + private final AttachmentRootGetter attachmentDirGetter; private final ExternalUrlSupplier externalUrl; - public LocalAttachmentUploadHandler(HaloProperties haloProp, + public LocalAttachmentUploadHandler(AttachmentRootGetter attachmentDirGetter, ExternalUrlSupplier externalUrl) { - this.haloProp = haloProp; + this.attachmentDirGetter = attachmentDirGetter; this.externalUrl = externalUrl; } - Path getAttachmentsRoot() { - return haloProp.getWorkDir().resolve("attachments"); - } - @Override public Mono upload(UploadContext uploadOption) { return Mono.just(uploadOption) @@ -78,7 +74,7 @@ public Mono upload(UploadContext uploadOption) { var settingJson = configMap.getData().getOrDefault("default", "{}"); var setting = JsonUtils.jsonToObject(settingJson, PolicySetting.class); - final var attachmentsRoot = getAttachmentsRoot(); + final var attachmentsRoot = attachmentDirGetter.get(); final var uploadRoot = attachmentsRoot.resolve("upload"); final var file = option.file(); final Path attachmentPath; @@ -195,7 +191,7 @@ public Mono delete(DeleteContext deleteContext) { if (annotations != null) { var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY); if (StringUtils.hasText(localRelativePath)) { - var attachmentsRoot = getAttachmentsRoot(); + var attachmentsRoot = attachmentDirGetter.get(); var attachmentPath = attachmentsRoot.resolve(localRelativePath); checkDirectoryTraversal(attachmentsRoot, attachmentPath); @@ -255,23 +251,6 @@ private boolean shouldHandle(Policy policy) { return "local".equals(policy.getSpec().getTemplateName()); } - @Data - public static class PolicySetting { - - private String location; - - private DataSize maxFileSize; - - private Set allowedFileTypes; - - public void setMaxFileSize(String maxFileSize) { - if (!StringUtils.hasText(maxFileSize)) { - return; - } - this.maxFileSize = DataSize.parse(maxFileSize); - } - } - /** * Write content into file. We will detect duplicate filename and auto-rename it with 3 times * retry. @@ -307,4 +286,21 @@ private Mono writeContent(Flux content, .then(Mono.fromSupplier(pathRef::get)); }); } + + @Data + public static class PolicySetting { + + private String location; + + private DataSize maxFileSize; + + private Set allowedFileTypes; + + public void setMaxFileSize(String maxFileSize) { + if (!StringUtils.hasText(maxFileSize)) { + return; + } + this.maxFileSize = DataSize.parse(maxFileSize); + } + } } diff --git a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java index f3b909181d..50f7bc7d0e 100644 --- a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java +++ b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java @@ -1,11 +1,7 @@ package run.halo.app.infra; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import run.halo.app.infra.utils.PathUtils; @@ -23,13 +19,9 @@ public class DefaultExternalLinkProcessor implements ExternalLinkProcessor { @Override public String processLink(String link) { var externalLink = externalUrlSupplier.getRaw(); - if (StringUtils.isBlank(link)) { + if (StringUtils.isBlank(link) || externalLink == null || PathUtils.isAbsoluteUri(link)) { return link; } - if (externalLink == null || !linkInSite(externalLink, link)) { - return link; - } - return append(externalLink.toString(), link); } @@ -37,18 +29,4 @@ String append(String externalLink, String link) { return StringUtils.removeEnd(externalLink, "/") + StringUtils.prependIfMissing(link, "/"); } - - boolean linkInSite(@NonNull URL externalUrl, @NonNull String link) { - if (!PathUtils.isAbsoluteUri(link)) { - // relative uri is always in site - return true; - } - try { - URI requestUri = new URI(link); - return StringUtils.equals(externalUrl.getAuthority(), requestUri.getAuthority()); - } catch (URISyntaxException e) { - // ignore this link - } - return false; - } } diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index 898627bff0..0825ce4470 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -36,8 +36,10 @@ import run.halo.app.core.extension.UserConnection.UserConnectionSpec; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; +import run.halo.app.core.extension.attachment.LocalThumbnail; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.PolicyTemplate; +import run.halo.app.core.extension.attachment.Thumbnail; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; @@ -466,8 +468,34 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event return size != null ? size.toString() : null; })) ); + indexSpecs.add(new IndexSpec() + .setName("status.permalink") + .setIndexFunc(simpleAttribute(Attachment.class, attachment -> { + var status = attachment.getStatus(); + return status == null ? null : status.getPermalink(); + })) + ); }); schemeManager.register(PolicyTemplate.class); + schemeManager.register(Thumbnail.class, indexSpec -> { + indexSpec.add(new IndexSpec() + .setName(Thumbnail.ID_INDEX) + .setIndexFunc(simpleAttribute(Thumbnail.class, Thumbnail::idIndexFunc)) + ); + }); + schemeManager.register(LocalThumbnail.class, indexSpec -> { + indexSpec.add(new IndexSpec() + .setName("spec.imageSignature") + .setIndexFunc(simpleAttribute(LocalThumbnail.class, + thumbnail -> thumbnail.getSpec().getImageSignature()) + )); + indexSpec.add(new IndexSpec() + .setName("spec.thumbSignature") + .setUnique(true) + .setIndexFunc(simpleAttribute(LocalThumbnail.class, + thumbnail -> thumbnail.getSpec().getThumbSignature()) + )); + }); // metrics.halo.run schemeManager.register(Counter.class); // auth.halo.run diff --git a/application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java new file mode 100644 index 0000000000..fecde3c9db --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java @@ -0,0 +1,169 @@ +package run.halo.app.theme.endpoint; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import static org.apache.commons.lang3.StringUtils.removeStart; +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.fn.builders.operation.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentUtils; +import run.halo.app.core.attachment.LocalThumbnailService; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; + +/** + * Thumbnail endpoint for thumbnail resource access. + * + * @author guqing + * @since 2.19.0 + */ +@Component +@RequiredArgsConstructor +public class ThumbnailEndpoint implements CustomEndpoint { + private final LocalThumbnailService localThumbnailService; + private final WebProperties webProperties; + private final ThumbnailService thumbnailService; + + @Override + public RouterFunction endpoint() { + var tag = "ThumbnailV1alpha1Public"; + return SpringdocRouteBuilder.route() + .GET("/thumbnails/-/via-uri", this::getThumbnailByUri, builder -> { + builder.operationId("GetThumbnailByUri") + .description("Get thumbnail by URI") + .tag(tag) + .response(responseBuilder() + .implementation(Resource.class)); + ThumbnailQuery.buildParameters(builder); + }) + .build(); + } + + private Mono getThumbnailByUri(ServerRequest request) { + var query = new ThumbnailQuery(request.queryParams()); + return thumbnailService.generate(query.getUri(), query.getWidth()) + .defaultIfEmpty(query.getUri()) + .flatMap(uri -> ServerResponse.permanentRedirect(uri).build()); + } + + static class ThumbnailQuery { + private final MultiValueMap params; + + public ThumbnailQuery(MultiValueMap params) { + this.params = params; + } + + @Schema(requiredMode = REQUIRED) + public URI getUri() { + var uriStr = params.getFirst("uri"); + if (StringUtils.isBlank(uriStr)) { + throw new ServerWebInputException("Required parameter 'uri' is missing"); + } + return AttachmentUtils.encodeUri(uriStr); + } + + @Schema(requiredMode = REQUIRED) + public ThumbnailSize getWidth() { + var width = params.getFirst("width"); + if (StringUtils.isBlank(width)) { + throw new ServerWebInputException("Required parameter 'width' is missing"); + } + // Remove the 'w' prefix + return ThumbnailSize.fromWidth(removeStart(width, 'w')); + } + + public static void buildParameters(Builder builder) { + builder.parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("uri") + .description("The URI of the image") + .required(true)) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("width") + .description("The width of the thumbnail") + .required(true)); + } + } + + @Bean + RouterFunction localThumbnailResourceRouter() { + return RouterFunctions.route() + .GET("/upload/thumbnails/{year}/w{width}/{fileName}", request -> { + var width = request.pathVariable("width"); + var year = request.pathVariable("year"); + var fileName = request.pathVariable("fileName"); + var size = ThumbnailSize.fromWidth(width); + var thumbnailUri = localThumbnailService.buildThumbnailUri(year, size, fileName); + return localThumbnailService.getThumbnail(thumbnailUri) + .flatMap(resource -> getResourceResponse(request, resource)) + .switchIfEmpty(Mono.defer( + () -> localThumbnailService.getOriginalImageUri(thumbnailUri) + .flatMap(this::fallback)) + ); + }) + + .build(); + } + + @Override + public GroupVersion groupVersion() { + return new GroupVersion("api.storage.halo.run", "v1alpha1"); + } + + private Mono getResourceResponse(ServerRequest request, Resource resource) { + var resourceProperties = webProperties.getResources(); + final var useLastModified = resourceProperties.getCache().isUseLastModified(); + final var cacheControl = getCacheControl(resourceProperties); + var bodyBuilder = ServerResponse.ok().cacheControl(cacheControl); + try { + if (useLastModified) { + var lastModified = Instant.ofEpochMilli(resource.lastModified()); + return request.checkNotModified(lastModified) + .switchIfEmpty(Mono.defer(() -> bodyBuilder.lastModified(lastModified) + .body(BodyInserters.fromResource(resource)) + )); + } + return bodyBuilder.body(BodyInserters.fromResource(resource)); + } catch (IOException e) { + return Mono.error(e); + } + } + + private Mono fallback(URI imageUri) { + return ServerResponse.temporaryRedirect(imageUri).build(); + } + + private static CacheControl getCacheControl(WebProperties.Resources resourceProperties) { + var cacheControl = resourceProperties.getCache() + .getCachecontrol() + .toHttpCacheControl(); + if (cacheControl == null) { + cacheControl = CacheControl.empty(); + } + return cacheControl; + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/ThumbnailFinder.java b/application/src/main/java/run/halo/app/theme/finders/ThumbnailFinder.java new file mode 100644 index 0000000000..32bb407308 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/ThumbnailFinder.java @@ -0,0 +1,20 @@ +package run.halo.app.theme.finders; + +import reactor.core.publisher.Mono; + +/** + * A dialect expression for image thumbnail. + * + * @author guqing + * @since 2.19.0 + */ +public interface ThumbnailFinder { + + /** + * Generate thumbnail url from given image url and size. + * + * @param size the size of thumbnail to generate + * @return the generated thumbnail url + */ + Mono gen(String url, String size); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java new file mode 100644 index 0000000000..5ad7f4984a --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java @@ -0,0 +1,22 @@ +package run.halo.app.theme.finders.impl; + +import java.net.URI; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.ThumbnailService; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.ThumbnailFinder; + +@Finder("thumbnail") +@RequiredArgsConstructor +public class ThumbnailFinderImpl implements ThumbnailFinder { + private final ThumbnailService thumbnailService; + + @Override + public Mono gen(String url, String size) { + return thumbnailService.generate(URI.create(url), ThumbnailSize.fromName(size)) + .map(URI::toString) + .defaultIfEmpty(url); + } +} diff --git a/application/src/main/resources/extensions/extension-definitions.yaml b/application/src/main/resources/extensions/extension-definitions.yaml index b654fd16e7..b8f871c4e8 100644 --- a/application/src/main/resources/extensions/extension-definitions.yaml +++ b/application/src/main/resources/extensions/extension-definitions.yaml @@ -47,3 +47,13 @@ spec: displayName: "Lucene 搜索引擎" description: "Halo 自带的本地搜索引擎" icon: /images/extension-points/lucene.png +--- +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionDefinition +metadata: + name: post-content-thumbnail-handler +spec: + className: run.halo.app.content.PostContentThumbnailHandler + extensionPointName: reactive-post-content-handler + displayName: "文章内容缩略图处理" + description: "处理文章的 HTML 内容为 img 标签追加缩略图" diff --git a/application/src/main/resources/extensions/extensionpoint-definitions.yaml b/application/src/main/resources/extensions/extensionpoint-definitions.yaml index 2e098119c2..7e06522fd6 100644 --- a/application/src/main/resources/extensions/extensionpoint-definitions.yaml +++ b/application/src/main/resources/extensions/extensionpoint-definitions.yaml @@ -97,3 +97,13 @@ spec: displayName: 摘要生成器 type: SINGLETON description: "提供自动生成摘要的方式扩展,如使用算法提取或使用 AI 生成。" +--- +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionPointDefinition +metadata: + name: thumbnail-provider +spec: + className: run.halo.app.core.attachment.ThumbnailProvider + displayName: 图片缩略图生成 + type: MULTI_INSTANCE + description: "提供生成图片缩略图的扩展方式" diff --git a/application/src/main/resources/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml index 06e23a7a8e..8f2ffce10e 100644 --- a/application/src/main/resources/extensions/role-template-anonymous.yaml +++ b/application/src/main/resources/extensions/role-template-anonymous.yaml @@ -45,4 +45,7 @@ rules: verbs: [ "get", "list" ] - apiGroups: [ "api.notification.halo.run" ] resources: [ "subscriptions/unsubscribe" ] + verbs: [ "get", "list" ] + - apiGroups: [ "api.storage.halo.run" ] + resources: [ "thumbnails/via-uri" ] verbs: [ "get", "list" ] \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/content/HtmlThumbnailSrcsetInjectorTest.java b/application/src/test/java/run/halo/app/content/HtmlThumbnailSrcsetInjectorTest.java new file mode 100644 index 0000000000..5976457863 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/HtmlThumbnailSrcsetInjectorTest.java @@ -0,0 +1,46 @@ +package run.halo.app.content; + +import static org.assertj.core.api.Assertions.assertThat; +import static run.halo.app.content.HtmlThumbnailSrcsetInjector.buildSizesAttr; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Tests for {@link HtmlThumbnailSrcsetInjector}. + * + * @author guqing + * @since 2.19.0 + */ +class HtmlThumbnailSrcsetInjectorTest { + + @Test + void injectSrcset() { + String html = """ +
+ test + +
+ """; + var result = HtmlThumbnailSrcsetInjector.injectSrcset(html, + src -> Mono.just(src + " 480w, " + src + " 800w")).block(); + assertThat(result).isEqualToIgnoringWhitespace(""" +
+ test + +
+ """); + } + + @Test + void buildSizesTest() { + var sizes = buildSizesAttr(); + assertThat(sizes).isEqualToIgnoringWhitespace(""" + (max-width: 400px) 400px, (max-width: 800px) 800px, + (max-width: 1200px) 1200px, (max-width: 1600px) 1600px + """); + } +} diff --git a/application/src/test/java/run/halo/app/core/attachment/AttachmentUtilsTest.java b/application/src/test/java/run/halo/app/core/attachment/AttachmentUtilsTest.java new file mode 100644 index 0000000000..821679ffd0 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/AttachmentUtilsTest.java @@ -0,0 +1,27 @@ +package run.halo.app.core.attachment; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link AttachmentUtils}. + * + * @author guqing + * @since 2.19.0 + */ +class AttachmentUtilsTest { + + @Test + void encodeUriTest() { + String urlStr = + "http://localhost:8090/upload/2022/06/CleanShot 2022-06-12 at 23.30.22@2x.webp"; + var result = AttachmentUtils.encodeUri(urlStr); + var targetUriStr = + "http://localhost:8090/upload/2022/06/CleanShot%202022-06-12%20at%2023.30.22@2x.webp"; + assertThat(result.toString()).isEqualTo(targetUriStr); + + result = AttachmentUtils.encodeUri(targetUriStr); + assertThat(result.toString()).isEqualTo(targetUriStr); + } +} diff --git a/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java b/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java new file mode 100644 index 0000000000..e50da39ba9 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java @@ -0,0 +1,91 @@ +package run.halo.app.core.attachment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Tests for {@link ThumbnailGenerator}. + * + * @author guqing + * @since 2.19.0 + */ +@ExtendWith(MockitoExtension.class) +class ThumbnailGeneratorTest { + + @Test + void sanitizeFileName() { + String sanitizedFileName = ThumbnailGenerator.sanitizeFileName("example.jpg"); + assertThat(sanitizedFileName).isEqualTo("example.jpg"); + + sanitizedFileName = ThumbnailGenerator.sanitizeFileName("exampl./e$%^7*—=.jpg"); + assertThat(sanitizedFileName).isEqualTo("exampl.e7.jpg"); + } + + @Nested + class ImageDownloaderTest { + private final ThumbnailGenerator.ImageDownloader imageDownloader = + new ThumbnailGenerator.ImageDownloader(); + + private Path tempFile; + + @AfterEach + void tearDown() throws IOException { + if (tempFile != null && Files.exists(tempFile)) { + Files.delete(tempFile); + } + } + + @Test + void testDownloadImage_Success() throws Exception { + var imageUrl = new URL("https://example.com/sample-image.jpg"); + URL spyImageUrl = spy(imageUrl); + String mockImageData = "fakeImageData"; + InputStream mockInputStream = new ByteArrayInputStream(mockImageData.getBytes()); + + doAnswer(invocation -> mockInputStream).when(spyImageUrl).openStream(); + + var path = imageDownloader.downloadFileInternal(spyImageUrl); + assertThat(path).isNotNull(); + tempFile = path; + assertThat(Files.exists(path)).isTrue(); + try { + assertThat(Files.size(path)).isEqualTo(mockImageData.length()); + } catch (IOException e) { + throw new RuntimeException(e); + } + String fileName = path.getFileName().toString(); + assertThat(fileName).endsWith(".tmp"); + } + + @Test + void downloadImage_FileSizeLimitExceeded() throws Exception { + String largeImageUrl = "https://example.com/large-image.jpg"; + URL spyImageUrl = spy(new URL(largeImageUrl)); + + // larger than MAX_FILE_SIZE + var fileSizeByte = ThumbnailGenerator.MAX_FILE_SIZE + 10; + byte[] largeImageData = new byte[fileSizeByte]; + InputStream mockInputStream = new ByteArrayInputStream(largeImageData); + + doReturn(mockInputStream).when(spyImageUrl).openStream(); + assertThatThrownBy(() -> imageDownloader.downloadFileInternal(spyImageUrl)) + .isInstanceOf(IOException.class) + .hasMessageContaining("File size exceeds the limit"); + } + } +} diff --git a/application/src/test/java/run/halo/app/core/attachment/ThumbnailSignerTest.java b/application/src/test/java/run/halo/app/core/attachment/ThumbnailSignerTest.java new file mode 100644 index 0000000000..50603e6d2b --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/ThumbnailSignerTest.java @@ -0,0 +1,21 @@ +package run.halo.app.core.attachment; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ThumbnailSigner}. + * + * @author guqing + * @since 2.19.0 + */ +class ThumbnailSignerTest { + + @Test + void generateSignature() { + var signature = ThumbnailSigner.generateSignature("example.jpg"); + assertThat(signature).isEqualTo( + "ed2e575a1d298e08df19dca9733da3f46eab5b157b1b8f9ed02f31b2f907ec79"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImplTest.java new file mode 100644 index 0000000000..e14f7a33c4 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/impl/AttachmentRootGetterImplTest.java @@ -0,0 +1,35 @@ +package run.halo.app.core.attachment.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.infra.properties.HaloProperties; + +/** + * Tests for {@link AttachmentRootGetterImpl}. + * + * @author guqing + * @since 2.19.0 + */ +@ExtendWith(MockitoExtension.class) +class AttachmentRootGetterImplTest { + @Mock + private HaloProperties haloProperties; + + @InjectMocks + private AttachmentRootGetterImpl localAttachmentDirGetter; + + @Test + void get() { + var rootPath = Path.of("/tmp"); + when(haloProperties.getWorkDir()).thenReturn(rootPath); + var dir = localAttachmentDirGetter.get(); + assertThat(dir).isEqualTo(rootPath.resolve("attachments")); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java new file mode 100644 index 0000000000..1cfedc2b31 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java @@ -0,0 +1,141 @@ +package run.halo.app.core.attachment.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalUrlSupplier; + +/** + * Tests for {@link LocalThumbnailServiceImpl}. + * + * @author guqing + * @since 2.19.0 + */ +@ExtendWith(MockitoExtension.class) +class LocalThumbnailServiceImplTest { + + @Mock + private AttachmentRootGetter attachmentWorkDirGetter; + + @Mock + private ExternalUrlSupplier externalUrlSupplier; + + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private LocalThumbnailServiceImpl localThumbnailService; + + @Test + void endpointForTest() { + var endpoint = + localThumbnailService.buildThumbnailUri("2024", ThumbnailSize.L, "example.jpg"); + assertThat(endpoint).isEqualTo("/upload/thumbnails/2024/w1200/example.jpg"); + } + + @Test + void geImageFileNameTest() throws MalformedURLException { + var fileName = + LocalThumbnailServiceImpl.geImageFileName(new URL("https://halo.run/example.jpg")); + assertThat(fileName).isEqualTo("example.jpg"); + + fileName = LocalThumbnailServiceImpl.geImageFileName(new URL("https://halo.run/")); + assertThat(fileName).isNotBlank(); + + fileName = LocalThumbnailServiceImpl.geImageFileName( + new URL("https://halo.run/.1fasfg(*&^%$.jpg")); + assertThat(fileName).isNotBlank(); + } + + @Test + void appendRandomSuffixTest() { + var result = LocalThumbnailServiceImpl.appendRandomSuffix("example.jpg"); + assertThat(result).isNotEqualTo("example.jpg"); + assertThat(result).endsWith(".jpg"); + assertThat(result).startsWith("example_"); + } + + @Test + void toFilePathTest() { + when(attachmentWorkDirGetter.get()).thenReturn(Path.of("/tmp")); + var path = localThumbnailService.toFilePath("/thumbnails/2024/w1200/example.jpg"); + assertThat(path).isEqualTo(Path.of("/tmp/thumbnails/2024/w1200/example.jpg")); + } + + @Test + void signatureForImageUriTest() throws MalformedURLException { + when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090")); + var signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); + assertThat(signature).isEqualTo(LocalThumbnail.signatureFor("/example.jpg")); + + signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); + assertThat(signature).isEqualTo(LocalThumbnail.signatureFor("/example.jpg")); + + signature = signatureForImageUriStr("http://localhost:8091/example.jpg"); + assertThat(signature).isEqualTo( + LocalThumbnail.signatureFor("http://localhost:8091/example.jpg")); + + signature = signatureForImageUriStr("localhost:8090/example.jpg"); + assertThat(signature).isEqualTo( + LocalThumbnail.signatureFor("localhost:8090/example.jpg")); + + when(externalUrlSupplier.getRaw()).thenReturn(null); + signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); + assertThat(signature).isEqualTo( + LocalThumbnail.signatureFor("http://localhost:8090/example.jpg")); + } + + String signatureForImageUriStr(String uriStr) { + return localThumbnailService.signatureForImageUri(URI.create(uriStr)); + } + + @Test + void generateUniqueThumbFileNameTest() { + var count = new AtomicInteger(0); + when(client.listBy(eq(LocalThumbnail.class), any(), isA(PageRequest.class))) + .thenAnswer(invocation -> { + if (count.get() > 2) { + return Mono.just(ListResult.emptyResult()); + } + count.incrementAndGet(); + var result = new ListResult<>(List.of(new LocalThumbnail())); + return Mono.just(result); + }); + + localThumbnailService.generateUniqueThumbFileName("example.jpg", "2024", ThumbnailSize.L) + .as(StepVerifier::create) + .consumeNextWith(fileName -> { + assertThat(fileName).startsWith("example_"); + assertThat(fileName).endsWith(".jpg"); + // 6 is the length of the random suffix + assertThat(fileName.length()).isEqualTo("example_.jpg".length() + 6); + }) + .verifyComplete(); + + verify(client, times(4)).listBy(eq(LocalThumbnail.class), any(), isA(PageRequest.class)); + } +} diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java new file mode 100644 index 0000000000..7a22f73d4a --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java @@ -0,0 +1,172 @@ +package run.halo.app.core.attachment.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.skyscreamer.jsonassert.JSONAssert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.attachment.LocalThumbnailProvider; +import run.halo.app.core.attachment.LocalThumbnailService; +import run.halo.app.core.attachment.ThumbnailProvider; +import run.halo.app.core.attachment.ThumbnailSigner; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.attachment.Thumbnail; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +/** + * Tests for {@link ThumbnailServiceImpl}. + * + * @author guqing + * @since 2.19.0 + */ +@ExtendWith(MockitoExtension.class) +class ThumbnailServiceImplTest { + @Mock + private ExternalLinkProcessor externalLinkProcessor; + + @Mock + private ExtensionGetter extensionGetter; + + @Mock + private LocalThumbnailProvider localThumbnailProvider; + + @Mock + private LocalThumbnailService localThumbnailService; + + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private ThumbnailServiceImpl thumbnailService; + + @Test + void toImageUrl() { + var link = "/test.jpg"; + when(externalLinkProcessor.processLink(link)).thenReturn("http://localhost:8090/test.jpg"); + var imageUrl = thumbnailService.toImageUrl(URI.create(link)); + assertThat(imageUrl).isPresent(); + assertThat(imageUrl.get().toString()).isEqualTo("http://localhost:8090/test.jpg"); + + var absoluteLink = "https://halo.run/test.jpg"; + imageUrl = thumbnailService.toImageUrl(URI.create(absoluteLink)); + assertThat(imageUrl).isPresent(); + assertThat(imageUrl.get().toString()).isEqualTo(absoluteLink); + } + + @Test + void generateTest() { + var uri = URI.create("http://localhost:8090/test.jpg"); + var size = ThumbnailSize.L; + when(localThumbnailService.ensureInSiteUriIsRelative(eq(uri))) + .thenReturn(uri); + var imageHash = ThumbnailSigner.generateSignature(uri.toString()); + var id = Thumbnail.idIndexFunc(imageHash, size.name()); + var listOptions = ListOptions.builder() + .fieldQuery(equal(Thumbnail.ID_INDEX, id)) + .build(); + when(client.listBy(eq(Thumbnail.class), any(), any())).thenReturn(Mono.empty()); + + var spyThumbnailService = spy(thumbnailService); + doReturn(Mono.empty()).when(spyThumbnailService).create(any(), any()); + + spyThumbnailService.generate(uri, size) + .as(StepVerifier::create) + .verifyComplete(); + + verify(client).listBy(eq(Thumbnail.class), assertArg(options -> { + assertThat(options.toString()).isEqualTo(listOptions.toString()); + }), isA(PageRequest.class)); + } + + @Test + void createTest() throws MalformedURLException, URISyntaxException { + var url = new URL("http://localhost:8090/test.jpg"); + when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class))) + .thenReturn(Flux.just(localThumbnailProvider)); + var thumbUri = URI.create("/test-thumb.jpg"); + when(localThumbnailProvider.generate(any())).thenReturn(Mono.just(thumbUri)); + when(localThumbnailProvider.supports(any())).thenReturn(Mono.just(true)); + + var insiteUri = URI.create("/test.jpg"); + when(localThumbnailService.ensureInSiteUriIsRelative(any())) + .thenReturn(insiteUri); + when(client.create(any())).thenReturn(Mono.empty()); + + thumbnailService.create(url, ThumbnailSize.M) + .as(StepVerifier::create) + .expectNext(thumbUri) + .verifyComplete(); + + when(client.listBy(eq(Thumbnail.class), any(), isA(PageRequest.class))) + .thenReturn(Mono.empty()); + thumbnailService.fetchThumbnail(url.toURI(), ThumbnailSize.M) + .as(StepVerifier::create) + .verifyComplete(); + var hash = ThumbnailSigner.generateSignature(insiteUri.toString()); + + verify(client).listBy(eq(Thumbnail.class), assertArg(options -> { + var exceptOptions = ListOptions.builder() + .fieldQuery(equal(Thumbnail.ID_INDEX, + Thumbnail.idIndexFunc(hash, ThumbnailSize.M.name()) + )) + .build(); + assertThat(options.toString()).isEqualTo(exceptOptions.toString()); + }), isA(PageRequest.class)); + + verify(localThumbnailProvider).generate(any()); + + verify(client).create(assertArg(thumb -> { + JSONAssert.assertEquals(""" + { + "spec": { + "imageSignature": "%s", + "imageUri": "/test.jpg", + "size": "M", + "thumbnailUri": "/test-thumb.jpg" + }, + "apiVersion": "storage.halo.run/v1alpha1", + "kind": "Thumbnail", + "metadata": { + "generateName": "thumb-" + } + } + """.formatted(hash), JsonUtils.objectToJson(thumb), true); + })); + } + + @Test + void createTest2() throws MalformedURLException { + when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class))) + .thenReturn(Flux.empty()); + + // no thumbnail provider will do nothing + var url = new URL("http://localhost:8090/test.jpg"); + thumbnailService.create(url, ThumbnailSize.M) + .as(StepVerifier::create) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java index 257dbb2eea..98b09cf0ed 100644 --- a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java +++ b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java @@ -45,5 +45,7 @@ void process() throws MalformedURLException { when(externalUrlSupplier.getRaw()).thenReturn(URI.create("https://halo.run/").toURL()); assertThat(externalLinkProcessor.processLink("/test")).isEqualTo("https://halo.run/test"); + assertThat(externalLinkProcessor.processLink("https://halo.run/test")) + .isEqualTo("https://halo.run/test"); } -} \ No newline at end of file +} diff --git a/platform/application/build.gradle b/platform/application/build.gradle index 18f3715181..13bb84bdd1 100644 --- a/platform/application/build.gradle +++ b/platform/application/build.gradle @@ -23,6 +23,7 @@ ext { resilience4jVersion = "2.2.0" twoFactorAuth = "1.3" tika = "2.9.2" + imgscalr = '4.2' } javaPlatform { @@ -56,6 +57,7 @@ dependencies { api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion" api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth" api "org.apache.tika:tika-core:$tika" + api "org.imgscalr:imgscalr-lib:$imgscalr" } } diff --git a/ui/console-src/modules/contents/attachments/AttachmentList.vue b/ui/console-src/modules/contents/attachments/AttachmentList.vue index 17fe87a414..bb601a7f55 100644 --- a/ui/console-src/modules/contents/attachments/AttachmentList.vue +++ b/ui/console-src/modules/contents/attachments/AttachmentList.vue @@ -509,7 +509,10 @@ watch( v-if="isImage(attachment.spec.mediaType)" :key="attachment.metadata.name" :alt="attachment.spec.displayName" - :src="attachment.status?.permalink" + :src=" + attachment.status?.thumbnails?.S || + attachment.status?.permalink + " classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu" >