diff --git a/dbflute_fess/dfprop/esfluteMap.dfprop b/dbflute_fess/dfprop/esfluteMap.dfprop
index d1287c8017..f7d3ab560b 100644
--- a/dbflute_fess/dfprop/esfluteMap.dfprop
+++ b/dbflute_fess/dfprop/esfluteMap.dfprop
@@ -129,6 +129,11 @@ map:{
; esclientDiFile = esclient.xml
; esfluteDiFile = esflute_config.xml
}
+ ; fess_config.tag_type = map:{
+ ; package = config
+ ; esclientDiFile = esclient.xml
+ ; esfluteDiFile = esflute_config.xml
+ }
; fess_config.thumbnail_queue = map:{
; package = config
; esclientDiFile = esclient.xml
diff --git a/src/main/config/es/fess_config_label_type.json b/src/main/config/es/fess_config_label_type.json
index b3daff8835..65abd1438c 100644
--- a/src/main/config/es/fess_config_label_type.json
+++ b/src/main/config/es/fess_config_label_type.json
@@ -16,9 +16,6 @@
"includedPaths" : {
"type" : "keyword"
},
- "kind" : {
- "type" : "keyword"
- },
"name" : {
"type" : "keyword"
},
@@ -65,4 +62,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/config/es/fess_config_tag_type.json b/src/main/config/es/fess_config_tag_type.json
new file mode 100644
index 0000000000..19c144b0ca
--- /dev/null
+++ b/src/main/config/es/fess_config_tag_type.json
@@ -0,0 +1,65 @@
+{
+ "fess_config.tag_type" : {
+ "aliases" : { },
+ "mappings" : {
+ "tag_type" : {
+ "properties" : {
+ "createdBy" : {
+ "type" : "keyword"
+ },
+ "createdTime" : {
+ "type" : "long"
+ },
+ "name" : {
+ "type" : "keyword"
+ },
+ "owner" : {
+ "type" : "keyword"
+ },
+ "paths" : {
+ "type" : "keyword"
+ },
+ "permissions" : {
+ "type" : "keyword"
+ },
+ "sortOrder" : {
+ "type" : "integer"
+ },
+ "updatedBy" : {
+ "type" : "keyword"
+ },
+ "updatedTime" : {
+ "type" : "long"
+ },
+ "virtualHost" : {
+ "type" : "keyword"
+ }
+ }
+ }
+ },
+ "settings" : {
+ "index" : {
+ "dbflute" : {
+ "tag_type" : {
+ "properties" : {
+ "paths" : {
+ "array" : "true"
+ },
+ "permissions" : {
+ "array" : "true"
+ }
+ }
+ }
+ },
+ "creation_date" : "1509021055215",
+ "number_of_shards" : "5",
+ "number_of_replicas" : "1",
+ "uuid" : "Zylt7BsVRhiOasQfUWBg0w",
+ "version" : {
+ "created" : "6000051"
+ },
+ "provided_name" : "fess_config.tag_type"
+ }
+ }
+ }
+}
diff --git a/src/main/config/openapi/v2/openapi-user.yaml b/src/main/config/openapi/v2/openapi-user.yaml
index 97fa78f4a1..22225e748e 100644
--- a/src/main/config/openapi/v2/openapi-user.yaml
+++ b/src/main/config/openapi/v2/openapi-user.yaml
@@ -72,7 +72,13 @@ tags:
- name: favorite
description: Favorite operations
- name: tag
- description: Tags that users add to documents
+ description: |-
+ Tags of logged-in users. Each user creates and manages their own tags
+ and puts them on documents; a shared tag of another user can be seen
+ and filtered on but not changed. Every tag endpoint answers
+ `invalid_request` (400) while `user.tag.enabled` is false, and
+ `auth_required` (401) to a caller without a login session: an access
+ token never stands in for a login here.
- name: auth
description: Authentication and session operations
- name: ui
@@ -1088,6 +1094,122 @@ paths:
'415': { $ref: '#/components/responses/UnsupportedMediaType' }
'500': { $ref: '#/components/responses/InternalServerError' }
+ /tags:
+ get:
+ tags: [tag]
+ summary: List the caller's tags
+ operationId: tagsListV2
+ responses:
+ '200':
+ description: The caller's tags, by sort order and name.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagsListResponse'
+ '400': { $ref: '#/components/responses/BadRequest' }
+ '401': { $ref: '#/components/responses/Unauthorized' }
+ '405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '500': { $ref: '#/components/responses/InternalServerError' }
+ post:
+ tags: [tag]
+ summary: Create a tag
+ description: |-
+ Creates a tag of the caller. The name is NFKC-normalized, whitespace
+ runs collapse to one space and the ends are trimmed; it then has 1 to
+ `user.tag.name.max.length` characters and no control or format
+ characters. A caller has at most `user.tag.max.tags` tags
+ (`invalid_request`). A tag of the same name of the caller already
+ existing is `conflict` (409); the same name of another user is a
+ different tag.
+ operationId: tagsCreateV2
+ parameters:
+ - { $ref: '#/components/parameters/CsrfHeader' }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagCreateRequest'
+ responses:
+ '200':
+ description: Tag created.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagResponse'
+ '400': { $ref: '#/components/responses/BadRequest' }
+ '401': { $ref: '#/components/responses/Unauthorized' }
+ '403': { $ref: '#/components/responses/Forbidden' }
+ '405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '409': { $ref: '#/components/responses/Conflict' }
+ '413': { $ref: '#/components/responses/PayloadTooLarge' }
+ '415': { $ref: '#/components/responses/UnsupportedMediaType' }
+ '500': { $ref: '#/components/responses/InternalServerError' }
+
+ /tags/{tagId}:
+ parameters:
+ - { $ref: '#/components/parameters/TagId' }
+ put:
+ tags: [tag]
+ summary: Rename a tag or change whether it is shared
+ description: |-
+ Only the owner can change a tag: the shared tag of another user is
+ `forbidden` (403) and a tag the caller cannot see is `not_found` (404).
+ A new `name` renames the tag, which gives it a new `id` and `value`;
+ the documents carrying the old value get the new one when the tag
+ queue is next processed (about a minute). Renaming to a name the
+ caller already uses is `conflict` (409) and leaves the tag unchanged.
+ `shared` only changes who sees the tag. A write that keeps losing a
+ race with another writer is also `conflict`.
+ operationId: tagsUpdateV2
+ parameters:
+ - { $ref: '#/components/parameters/CsrfHeader' }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagUpdateRequest'
+ responses:
+ '200':
+ description: Tag updated.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagUpdateResponse'
+ '400': { $ref: '#/components/responses/BadRequest' }
+ '401': { $ref: '#/components/responses/Unauthorized' }
+ '403': { $ref: '#/components/responses/Forbidden' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '409': { $ref: '#/components/responses/Conflict' }
+ '413': { $ref: '#/components/responses/PayloadTooLarge' }
+ '415': { $ref: '#/components/responses/UnsupportedMediaType' }
+ '500': { $ref: '#/components/responses/InternalServerError' }
+ delete:
+ tags: [tag]
+ summary: Delete a tag
+ description: |-
+ Deletes a tag of the caller. The documents lose its value when the tag
+ queue is next processed. The shared tag of another user is `forbidden`
+ (403) and a tag the caller cannot see is `not_found` (404).
+ operationId: tagsDeleteV2
+ parameters:
+ - { $ref: '#/components/parameters/CsrfHeader' }
+ responses:
+ '200':
+ description: Tag deleted.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TagDeleteResponse'
+ '400': { $ref: '#/components/responses/BadRequest' }
+ '401': { $ref: '#/components/responses/Unauthorized' }
+ '403': { $ref: '#/components/responses/Forbidden' }
+ '404': { $ref: '#/components/responses/NotFound' }
+ '405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '500': { $ref: '#/components/responses/InternalServerError' }
+
/documents/{docId}/tags:
parameters:
- { $ref: '#/components/parameters/DocId' }
@@ -1095,15 +1217,14 @@ paths:
tags: [tag]
summary: List the tags of a document
description: |-
- Lists the tags of the document that the caller can see. A tag is a
- label of the kind `tag`: its name is the tag name, its included paths
- list the tagged URLs and its permissions decide who can see it.
- Answers `400` while `user.tag.enabled` is false, and `404` when the
- caller cannot see the document.
- operationId: documentTagsGetV2
+ Returns the tags on the document's URL that the caller can see and
+ the caller's own tags that are not on it. The document is resolved
+ with the caller's roles; a document the caller cannot search is
+ `not_found` (404).
+ operationId: documentTagsListV2
responses:
'200':
- description: Tags returned.
+ description: Tags of the document.
content:
application/json:
schema:
@@ -1115,16 +1236,16 @@ paths:
'500': { $ref: '#/components/responses/InternalServerError' }
post:
tags: [tag]
- summary: Tag a document
+ summary: Put a tag of the caller on a document
description: |-
- Tags the document's URL for the logged-in user; an access token does
- not stand in for a login. The name is NFKC-normalized, whitespace is
- collapsed and it is trimmed. When a tag of the name exists, the URL is
- added to its included paths and the user to its permissions; otherwise
- a tag that only the user can see is created. Tagging again answers
- `added: false`. `user.tag.max.document.tags` limits the tags of one
- document.
- operationId: documentTagsPostV2
+ Adds the document's URL to a tag of the caller, given by `id`, or by
+ `name`, in which case a private tag of the name is created when the
+ caller has none (counted against `user.tag.max.tags`). A tag holds at
+ most `user.tag.max.paths` URLs (`invalid_request`). Another user's
+ tag is `forbidden` (403) or, when not visible, `not_found` (404).
+ Every indexed document of the URL gets the tag when the tag queue is
+ next processed (about a minute); the tag endpoints show it at once.
+ operationId: documentTagsAddV2
parameters:
- { $ref: '#/components/parameters/CsrfHeader' }
requestBody:
@@ -1132,10 +1253,10 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/DocumentTagPostRequest'
+ $ref: '#/components/schemas/DocumentTagAddRequest'
responses:
'200':
- description: Document tagged, or already tagged by the user.
+ description: The tag is on the document.
content:
application/json:
schema:
@@ -1145,28 +1266,27 @@ paths:
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '409': { $ref: '#/components/responses/Conflict' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'415': { $ref: '#/components/responses/UnsupportedMediaType' }
'500': { $ref: '#/components/responses/InternalServerError' }
+
+ /documents/{docId}/tags/{tagId}:
+ parameters:
+ - { $ref: '#/components/parameters/DocId' }
+ - { $ref: '#/components/parameters/TagId' }
delete:
tags: [tag]
- summary: Remove the caller from a tag
+ summary: Take a tag of the caller off a document
description: |-
- Removes the logged-in user from the permissions of the tag. The tag is
- deleted, and removed from the documents, when no permission of a user,
- a group or a role is left. Answers `403` when the user is not in the
- permissions of the tag.
- operationId: documentTagsDeleteV2
+ Removes the document's URL from a tag of the caller. Another user's
+ tag is `forbidden` (403) or, when not visible, `not_found` (404).
+ operationId: documentTagsRemoveV2
parameters:
- { $ref: '#/components/parameters/CsrfHeader' }
- - name: value
- in: query
- required: true
- description: The tag value.
- schema: { type: string }
responses:
'200':
- description: The user was removed from the tag.
+ description: The tag is off the document.
content:
application/json:
schema:
@@ -1176,6 +1296,7 @@ paths:
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'405': { $ref: '#/components/responses/MethodNotAllowed' }
+ '409': { $ref: '#/components/responses/Conflict' }
'500': { $ref: '#/components/responses/InternalServerError' }
/cache/{docId}:
@@ -1223,6 +1344,12 @@ components:
description: Document identifier (matches `[A-Za-z0-9_-]+`).
required: true
schema: { type: string, minLength: 1, maxLength: 512, pattern: '^[A-Za-z0-9_-]+$' }
+ TagId:
+ name: tagId
+ in: path
+ description: Tag id, the SHA-256 of the tag value in lowercase hex.
+ required: true
+ schema: { type: string, pattern: '^[0-9a-f]{64}$' }
CsrfHeader:
name: X-Fess-CSRF-Token
in: header
@@ -1255,6 +1382,13 @@ components:
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
+ Conflict:
+ description: |-
+ The request conflicts with the current state (e.g. a tag of the name
+ already exists, or the tag kept changing concurrently).
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/ErrorEnvelope' }
MethodNotAllowed:
description: |-
HTTP method is not supported for this endpoint. The `Allow` header
@@ -1480,12 +1614,28 @@ components:
name: { type: string }
result:
type: array
+ description: |-
+ For the `tag` field, only the tags the caller can see,
+ each with `label`, `owner`, `mine` and `shared`; a
+ caller without a login gets no `tag` facet.
items:
type: object
required: [value, count]
properties:
value: { type: string }
count: { type: integer, format: int64 }
+ label:
+ type: string
+ description: '`tag` facet only: the tag name.'
+ owner:
+ type: string
+ description: '`tag` facet only: the user who owns the tag.'
+ mine:
+ type: boolean
+ description: '`tag` facet only: whether the caller owns the tag.'
+ shared:
+ type: boolean
+ description: '`tag` facet only: whether every logged-in user sees the tag.'
facet_query:
type: array
description: Present only when facet queries were requested.
@@ -1552,14 +1702,6 @@ components:
description: Stored as the string `"true"`/`"false"`, not a JSON boolean.
click_count: { type: integer, format: int64 }
favorite_count: { type: integer, format: int64 }
- tags:
- type: array
- description: |-
- The tags of the document that the caller can see. Present only while
- `user.tag.enabled` is true and the document has such tags. The `tag`
- index field itself is not returned.
- items:
- $ref: '#/components/schemas/DocumentTag'
similar_docs_count:
type: integer
format: int64
@@ -1571,8 +1713,31 @@ components:
description: |-
Present only when result collapsing is enabled. Never present on
`/documents/all`.
+ tags:
+ type: array
+ description: |-
+ `/search` only, while `user.tag.enabled` is true: the tags on the
+ document that the caller can see. Absent when there is none, and
+ always absent for a caller without a login. The raw `tag` field is
+ never returned.
+ items:
+ $ref: '#/components/schemas/HitTag'
additionalProperties: true
+ HitTag:
+ type: object
+ required: [value, name, owner, mine, shared]
+ properties:
+ value:
+ type: string
+ description: |-
+ Tag value, `base64url(name):base64url(owner)` without padding;
+ filter with `fields.tag=`.
+ name: { type: string }
+ owner: { type: string, description: User who owns the tag. }
+ mine: { type: boolean, description: Whether the caller owns the tag. }
+ shared: { type: boolean, description: Whether every logged-in user sees the tag. }
+
# --- Suggest / labels / popular ---------------------------------
SuggestWordsResponse:
type: object
@@ -1748,6 +1913,7 @@ components:
- user_favorite
- search_history
- search_export
+ - user_tag
- popular_word
- suggest_search_log
- suggest_documents
@@ -1780,8 +1946,8 @@ components:
user_tag:
type: boolean
description: |-
- `true` when users can see and add tags
- (`/documents/{docId}/tags`, `user.tag.enabled`).
+ `true` when user tags are enabled (`user.tag.enabled`):
+ the tag endpoints answer and search hits carry `tags`.
popular_word: { type: boolean }
suggest_search_log: { type: boolean }
suggest_documents: { type: boolean }
@@ -2324,27 +2490,102 @@ components:
Absent on the first POST that newly records the favorite.
# --- Tags --------------------------------------------------------
+ TagListItem:
+ type: object
+ required: [id, value, name, shared, sort_order, path_count]
+ properties:
+ id: { type: string, description: 'Tag id (SHA-256 of `value`, lowercase hex).' }
+ value: { type: string, description: 'Tag value, `base64url(name):base64url(owner)`.' }
+ name: { type: string }
+ shared: { type: boolean, description: Whether every logged-in user sees the tag. }
+ sort_order: { type: integer }
+ path_count: { type: integer, format: int64, description: Number of URLs the tag is on. }
+
DocumentTag:
type: object
- required: [value, name, mine]
+ required: [id, value, name, owner, mine, shared]
properties:
- value:
- type: string
- description: The value of the label of the tag; filter results with `fields.tag=`.
+ id: { type: string, description: 'Tag id (SHA-256 of `value`, lowercase hex).' }
+ value: { type: string, description: 'Tag value, `base64url(name):base64url(owner)`.' }
name: { type: string }
- mine:
- type: boolean
- description: '`true` when the caller is in the permissions of the tag.'
+ owner: { type: string, description: User who owns the tag. }
+ mine: { type: boolean, description: Whether the caller owns the tag. }
+ shared: { type: boolean, description: Whether every logged-in user sees the tag. }
+
+ TagsListResponse:
+ type: object
+ required: [response]
+ properties:
+ response:
+ allOf:
+ - $ref: '#/components/schemas/EnvelopeMeta'
+ - type: object
+ required: [tags]
+ properties:
+ tags:
+ type: array
+ items: { $ref: '#/components/schemas/TagListItem' }
- DocumentTagPostRequest:
+ TagCreateRequest:
type: object
required: [name]
properties:
- name:
- type: string
- description: |-
- 1 to `user.tag.name.max.length` (default 50) characters, without a
- control or format character.
+ name: { type: string, description: 'Tag name, 1 to `user.tag.name.max.length` characters after normalization.' }
+ shared: { type: boolean, default: false, description: Whether every logged-in user sees the tag. }
+
+ TagUpdateRequest:
+ type: object
+ description: At least one of `name` and `shared`.
+ properties:
+ name: { type: string, description: New tag name. }
+ shared: { type: boolean }
+
+ TagResponse:
+ type: object
+ required: [response]
+ properties:
+ response:
+ allOf:
+ - $ref: '#/components/schemas/EnvelopeMeta'
+ - type: object
+ required: [tag]
+ properties:
+ tag: { $ref: '#/components/schemas/TagListItem' }
+
+ TagUpdateResponse:
+ type: object
+ required: [response]
+ properties:
+ response:
+ allOf:
+ - $ref: '#/components/schemas/EnvelopeMeta'
+ - type: object
+ required: [tag, renamed]
+ properties:
+ tag: { $ref: '#/components/schemas/TagListItem' }
+ renamed:
+ type: boolean
+ description: Whether the tag was renamed, in which case `tag.id` and `tag.value` are new.
+
+ TagDeleteResponse:
+ type: object
+ required: [response]
+ properties:
+ response:
+ allOf:
+ - $ref: '#/components/schemas/EnvelopeMeta'
+ - type: object
+ required: [id, deleted]
+ properties:
+ id: { type: string }
+ deleted: { type: boolean, enum: [true] }
+
+ DocumentTagAddRequest:
+ type: object
+ description: Either `id` (a tag of the caller) or `name`; `id` wins when both are given.
+ properties:
+ id: { type: string, pattern: '^[0-9a-f]{64}$' }
+ name: { type: string }
DocumentTagsResponse:
type: object
@@ -2354,22 +2595,27 @@ components:
allOf:
- $ref: '#/components/schemas/EnvelopeMeta'
- type: object
- required: [doc_id, addable, tags]
+ required: [doc_id, tags, addable]
properties:
doc_id: { type: string }
- addable:
- type: boolean
- description: '`true` when the caller is logged in.'
added:
type: boolean
- description: POST only. `false` when the user had already tagged the document.
+ description: '`POST` only: `false` when the tag was already on the document.'
+ tag:
+ allOf:
+ - $ref: '#/components/schemas/DocumentTag'
+ description: '`POST` only: the tag that was put on the document.'
removed:
type: boolean
- description: DELETE only.
+ description: '`DELETE` only: `false` when the tag was not on the document.'
tags:
type: array
- items:
- $ref: '#/components/schemas/DocumentTag'
+ description: Tags on the document's URL that the caller can see.
+ items: { $ref: '#/components/schemas/DocumentTag' }
+ addable:
+ type: array
+ description: The caller's tags that are not on the document's URL.
+ items: { $ref: '#/components/schemas/DocumentTag' }
# --- Cache -------------------------------------------------------
CacheResponse:
diff --git a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java
index 52e3626f35..87eb980467 100644
--- a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java
+++ b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java
@@ -27,8 +27,8 @@
import org.codelibs.fess.api.v2.handlers.ChatSessionClearHandler;
import org.codelibs.fess.api.v2.handlers.ChatStreamHandler;
import org.codelibs.fess.api.v2.handlers.ClickHandler;
-import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler;
import org.codelibs.fess.api.v2.handlers.CsrfRequirement;
+import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler;
import org.codelibs.fess.api.v2.handlers.ExportSearchHandler;
import org.codelibs.fess.api.v2.handlers.FavoriteGetHandler;
import org.codelibs.fess.api.v2.handlers.FavoritePostHandler;
@@ -47,6 +47,7 @@
import org.codelibs.fess.api.v2.handlers.SearchHandler;
import org.codelibs.fess.api.v2.handlers.SearchHistoryHandler;
import org.codelibs.fess.api.v2.handlers.SuggestWordsHandler;
+import org.codelibs.fess.api.v2.handlers.TagsHandler;
import org.codelibs.fess.api.v2.handlers.UiConfigHandler;
import org.codelibs.fess.app.service.AccessTokenService;
import org.codelibs.fess.app.web.base.login.FessLoginAssist;
@@ -103,7 +104,11 @@ public class SearchApiV2Manager extends BaseApiManager {
@Resource
protected FavoritePostHandler favoritePostHandler;
- /** Handles {@code GET/POST/DELETE /api/v2/documents/{id}/tags} (list, add and remove user tags). */
+ /** Handles {@code GET/POST /api/v2/tags} and {@code PUT/DELETE /api/v2/tags/{id}} (the caller's own tags). */
+ @Resource
+ protected TagsHandler tagsHandler;
+
+ /** Handles {@code GET/POST /api/v2/documents/{id}/tags} and {@code DELETE /api/v2/documents/{id}/tags/{tagId}}. */
@Resource
protected DocumentTagsHandler documentTagsHandler;
@@ -307,11 +312,16 @@ public void process(final HttpServletRequest request, final HttpServletResponse
}
return;
}
- // "/documents/tags" leaves no doc id between the two ends, like "/documents/favorite" above.
- if (sub.startsWith("/documents/") && sub.endsWith("/tags") && sub.length() > "/documents/".length() + "/tags".length()) {
- final String docId = sub.substring("/documents/".length(), sub.length() - "/tags".length());
- documentTagsHandler.handle(request, response, docId);
- return;
+ // "/documents/{docId}/tags" and "/documents/{docId}/tags/{tagId}". The doc id and the tag
+ // id are single non-empty segments; anything else under /documents/ falls through to the
+ // not_found arm below.
+ if (sub.startsWith("/documents/")) {
+ final String[] segments = sub.substring("/documents/".length()).split("/", -1);
+ if ((segments.length == 2 || segments.length == 3) && !segments[0].isEmpty() && "tags".equals(segments[1])
+ && (segments.length == 2 || !segments[2].isEmpty())) {
+ documentTagsHandler.handle(request, response, segments[0], segments.length == 3 ? segments[2] : null);
+ return;
+ }
}
if (sub.startsWith("/cache/")) {
final String docId = sub.substring("/cache/".length());
@@ -331,6 +341,15 @@ public void process(final HttpServletRequest request, final HttpServletResponse
ComponentUtil.getV2EnvelopeWriter().writeError(response, V2ErrorCode.NOT_FOUND, "unknown action on document: " + sub);
return;
}
+ // "/tags" and "/tags/{tagId}": the tag id is one non-empty segment.
+ if ("/tags".equals(sub)) {
+ tagsHandler.handle(request, response, null);
+ return;
+ }
+ if (sub.startsWith("/tags/") && sub.indexOf('/', "/tags/".length()) < 0) {
+ tagsHandler.handle(request, response, sub.substring("/tags/".length()));
+ return;
+ }
switch (sub) {
case "/health" -> healthHandler.handle(request, response);
case "/search" -> searchHandler.handle(request, response);
diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java
new file mode 100644
index 0000000000..636cac6023
--- /dev/null
+++ b/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright 2012-2025 CodeLibs Project and the Others.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package org.codelibs.fess.api.v2.handlers;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.codelibs.core.lang.StringUtil;
+import org.codelibs.fess.api.v2.V2ErrorCode;
+import org.codelibs.fess.app.service.TagTypeService;
+import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType;
+import org.codelibs.fess.entity.TagChange;
+import org.codelibs.fess.exception.TagTypeConflictException;
+import org.codelibs.fess.helper.TagTypeHelper;
+import org.codelibs.fess.mylasta.action.FessUserBean;
+import org.codelibs.fess.opensearch.config.exentity.TagType;
+import org.codelibs.fess.util.ComponentUtil;
+import org.dbflute.optional.OptionalThing;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * Shared parts of the user tag endpoints ({@link TagsHandler} and {@link DocumentTagsHandler}).
+ *
+ * Every tag endpoint needs {@code user.tag.enabled} and a logged-in user: the user of the login session, which an
+ * access token never stands in for. A caller changes only the tags it owns ({@code owner} equals
+ * {@link FessUserBean#getUserId()}); the tag of someone else is reported as {@link V2ErrorCode#FORBIDDEN} when the
+ * caller can see it and as {@link V2ErrorCode#NOT_FOUND} otherwise, so that a hidden tag is not revealed.
+ */
+public abstract class AbstractTagHandler {
+
+ private static final Logger logger = LogManager.getLogger(AbstractTagHandler.class);
+
+ /** The request bodies hold a short name, an id and a flag. */
+ protected static final int MAX_BODY_BYTES = 1024;
+
+ /** How many times an update that lost a race with another writer is read and applied again. */
+ protected static final int MAX_UPDATE_ATTEMPTS = 3;
+
+ /** A tag type id: the SHA-256 of the tag value in lowercase hex. */
+ private static final Pattern TAG_ID_PATTERN = Pattern.compile("[0-9a-f]{64}");
+
+ /**
+ * Default constructor.
+ */
+ protected AbstractTagHandler() {
+ // no-op
+ }
+
+ /** A failure that is answered with an error envelope. */
+ protected static class TagRequestException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ /** The error code of the envelope. */
+ protected final transient V2ErrorCode code;
+
+ /**
+ * Creates the failure.
+ *
+ * @param code the error code of the envelope
+ * @param message the error message of the envelope
+ */
+ protected TagRequestException(final V2ErrorCode code, final String message) {
+ super(message, null, false, false);
+ this.code = code;
+ }
+ }
+
+ /** A change applied to a freshly read tag type before it is written back. */
+ @FunctionalInterface
+ protected interface TagTypeModifier {
+ /**
+ * Changes the tag type.
+ *
+ * @param tagType the tag type as stored, with its paths
+ * @return true if the tag type was changed and has to be written
+ * @throws TagRequestException if the change is refused
+ */
+ boolean modify(TagType tagType) throws TagRequestException;
+ }
+
+ /**
+ * Checks that user tags are enabled and returns the logged-in user.
+ *
+ * @return the user id of the caller
+ * @throws TagRequestException if user tags are disabled or nobody is logged in
+ */
+ protected String checkRequest() throws TagRequestException {
+ if (!ComponentUtil.getFessConfig().isUserTagEnabled()) {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "tag feature is not available");
+ }
+ final String userId = getUserBean().map(FessUserBean::getUserId).filter(StringUtil::isNotBlank).orElse(null);
+ if (userId == null) {
+ throw new TagRequestException(V2ErrorCode.AUTH_REQUIRED, "login required");
+ }
+ return userId;
+ }
+
+ /**
+ * Reads the JSON body of the request.
+ *
+ * @param req the request
+ * @return the body
+ * @throws TagRequestException if the body is not a small JSON object
+ * @throws IOException if the body cannot be read
+ */
+ protected Map readBody(final HttpServletRequest req) throws TagRequestException, IOException {
+ try {
+ return ComponentUtil.getV2JsonBody().read(req, MAX_BODY_BYTES);
+ } catch (final V2JsonBody.PayloadTooLargeException e) {
+ throw new TagRequestException(V2ErrorCode.PAYLOAD_TOO_LARGE, e.getMessage());
+ } catch (final V2JsonBody.UnsupportedMediaTypeException e) {
+ throw new TagRequestException(V2ErrorCode.UNSUPPORTED_MEDIA_TYPE, e.getMessage());
+ } catch (final V2JsonBody.MalformedJsonException e) {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, e.getMessage());
+ }
+ }
+
+ /**
+ * Returns the normalized tag name of a request.
+ *
+ * @param name the name in the request body
+ * @return the normalized name
+ * @throws TagRequestException if the name is not a valid tag name
+ */
+ protected String toTagName(final Object name) throws TagRequestException {
+ final String normalized = name instanceof final String value ? getTagTypeHelper().normalizeName(value).orElse(null) : null;
+ if (normalized == null) {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST,
+ "invalid tag name: enter 1 to " + ComponentUtil.getFessConfig().getUserTagNameMaxLengthAsInteger() + " characters");
+ }
+ return normalized;
+ }
+
+ /**
+ * Returns the {@code shared} flag of a request body.
+ *
+ * @param body the request body
+ * @return the flag, or null when the body has none
+ * @throws TagRequestException if the flag is not a boolean
+ */
+ protected Boolean toShared(final Map body) throws TagRequestException {
+ final Object shared = body.get("shared");
+ if (shared == null || shared instanceof Boolean) {
+ return (Boolean) shared;
+ }
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "shared must be a boolean");
+ }
+
+ /**
+ * Builds a new tag of the caller, checking {@code user.tag.max.tags}. The tag is not stored.
+ *
+ * @param name the normalized tag name
+ * @param userId the caller, who owns the tag
+ * @param shared whether the tag is shared
+ * @param paths the paths of the tag
+ * @return the tag type with its id set
+ * @throws TagRequestException if the caller has {@code user.tag.max.tags} tags
+ */
+ protected TagType newTagType(final String name, final String userId, final boolean shared, final String[] paths)
+ throws TagRequestException {
+ final int maxTags = ComponentUtil.getFessConfig().getUserTagMaxTagsAsInteger();
+ if (getTagTypeService().countByOwner(userId) >= maxTags) {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "too many tags: a user can have up to " + maxTags + " tags");
+ }
+ final TagTypeHelper helper = getTagTypeHelper();
+ final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
+ final TagType tagType = new TagType();
+ tagType.setName(name);
+ tagType.setOwner(userId);
+ tagType.setId(helper.toId(tagType.getTagValue()));
+ tagType.setPaths(paths);
+ tagType.setPermissions(helper.buildPermissions(userId, shared));
+ tagType.setVirtualHost(ComponentUtil.getVirtualHostHelper().getVirtualHostKey());
+ tagType.setSortOrder(0);
+ tagType.setCreatedBy(userId);
+ tagType.setCreatedTime(now);
+ tagType.setUpdatedBy(userId);
+ tagType.setUpdatedTime(now);
+ return tagType;
+ }
+
+ /**
+ * Reads a tag type that the caller owns, with its paths.
+ *
+ * @param id the tag type id
+ * @param userId the caller
+ * @return the tag type
+ * @throws TagRequestException NOT_FOUND if there is no such tag or the caller cannot see it, FORBIDDEN if the
+ * caller can see it but does not own it
+ */
+ protected TagType getOwnTagType(final String id, final String userId) throws TagRequestException {
+ final TagType tagType = id != null && TAG_ID_PATTERN.matcher(id).matches() ? getTagTypeService().getTagType(id).orElse(null) : null;
+ if (tagType == null) {
+ throw new TagRequestException(V2ErrorCode.NOT_FOUND, "tag not found");
+ }
+ if (!userId.equals(tagType.getOwner())) {
+ final String value = tagType.getTagValue();
+ if (getTagTypeHelper().getVisibleTagTypes(List.of(value), SearchRequestType.JSON).containsKey(value)) {
+ throw new TagRequestException(V2ErrorCode.FORBIDDEN, "the tag is not yours");
+ }
+ throw new TagRequestException(V2ErrorCode.NOT_FOUND, "tag not found");
+ }
+ return tagType;
+ }
+
+ /**
+ * Reads a tag type the caller owns, changes it and writes it back. A write that lost a race with another writer
+ * is retried on a fresh read, so that a concurrent change of the paths is not overwritten.
+ *
+ * @param id the tag type id
+ * @param userId the caller
+ * @param modifier the change
+ * @return the tag type as written, or as read when the modifier changed nothing
+ * @throws TagRequestException if the tag is not the caller's, the change is refused, or every attempt lost a race
+ */
+ protected TagType updateTagType(final String id, final String userId, final TagTypeModifier modifier) throws TagRequestException {
+ for (int attempt = 1;; attempt++) {
+ final TagType tagType = getOwnTagType(id, userId);
+ if (!modifier.modify(tagType)) {
+ return tagType;
+ }
+ tagType.setUpdatedBy(userId);
+ tagType.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
+ try {
+ getTagTypeService().update(tagType);
+ return tagType;
+ } catch (final TagTypeConflictException e) {
+ if (attempt >= MAX_UPDATE_ATTEMPTS) {
+ logger.warn("Failed to update the tag after {} attempts: id={}", attempt, id, e);
+ throw new TagRequestException(V2ErrorCode.CONFLICT, "the tag was changed concurrently; try again");
+ }
+ if (logger.isDebugEnabled()) {
+ logger.debug("The tag was changed concurrently; retrying: id={}, attempt={}", id, attempt);
+ }
+ }
+ }
+ }
+
+ /**
+ * Queues a change of the tag field of the documents.
+ *
+ * @param change the change
+ */
+ protected void enqueue(final TagChange change) {
+ if (ComponentUtil.getFessConfig().isUserTagEnabled()) {
+ getTagTypeHelper().enqueue(change);
+ }
+ }
+
+ /**
+ * Builds the JSON of a tag as a document or a search hit shows it.
+ *
+ * @param tagType the tag type
+ * @param userId the caller
+ * @return {@code {id, value, name, owner, mine, shared}}
+ */
+ protected Map toDocumentTag(final TagType tagType, final String userId) {
+ final String value = tagType.getTagValue();
+ final Map map = new LinkedHashMap<>();
+ map.put("id", getTagTypeHelper().toId(value));
+ map.put("value", value);
+ map.put("name", tagType.getName());
+ map.put("owner", tagType.getOwner());
+ map.put("mine", userId.equals(tagType.getOwner()));
+ map.put("shared", getTagTypeHelper().isShared(tagType));
+ return map;
+ }
+
+ /**
+ * Returns the tag type helper.
+ *
+ * @return the helper
+ */
+ protected TagTypeHelper getTagTypeHelper() {
+ return ComponentUtil.getTagTypeHelper();
+ }
+
+ /**
+ * Returns the tag type service. Exposed as a seam for unit tests.
+ *
+ * @return the service
+ */
+ protected TagTypeService getTagTypeService() {
+ return ComponentUtil.getComponent(TagTypeService.class);
+ }
+
+ /**
+ * Returns the user of the login session. An access token is not a login session. Exposed as a seam for unit
+ * tests.
+ *
+ * @return the logged-in user
+ */
+ protected OptionalThing getUserBean() {
+ return ComponentUtil.getFessLoginAssist().getSavedUserBean();
+ }
+}
diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java b/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java
index 446d0e3660..6fee937f56 100644
--- a/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java
+++ b/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java
@@ -89,9 +89,6 @@ public boolean requiresCsrf(final String subPath, final String method) {
if (subPath.startsWith("/documents/") && subPath.endsWith("/favorite")) {
return true;
}
- if (subPath.startsWith("/documents/") && subPath.endsWith("/tags")) {
- return true;
- }
if ("/chat".equals(subPath)) {
return true;
}
diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java
index 9129b77dca..59945a423e 100644
--- a/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java
+++ b/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java
@@ -17,52 +17,51 @@
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.codelibs.core.lang.StringUtil;
import org.codelibs.fess.api.v2.V2ErrorCode;
import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType;
-import org.codelibs.fess.helper.LabelTypeHelper.LabelTypeItem;
-import org.codelibs.fess.helper.TagHelper;
-import org.codelibs.fess.helper.TagHelper.AddResult;
-import org.codelibs.fess.mylasta.action.FessUserBean;
+import org.codelibs.fess.entity.TagChange;
+import org.codelibs.fess.exception.TagTypeConflictException;
+import org.codelibs.fess.helper.TagTypeHelper;
import org.codelibs.fess.mylasta.direction.FessConfig;
+import org.codelibs.fess.opensearch.config.exentity.TagType;
import org.codelibs.fess.util.ComponentUtil;
import org.codelibs.fess.util.DocumentUtil;
-import org.dbflute.optional.OptionalThing;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
- * Handles {@code GET}, {@code POST} and {@code DELETE /api/v2/documents/{docId}/tags}.
+ * Handles the tags of one document: {@code GET/POST /api/v2/documents/{docId}/tags} and
+ * {@code DELETE /api/v2/documents/{docId}/tags/{id}}.
*
- * A tag is a label type of the kind {@code tag}: its name is the tag name, its included paths list the tagged
- * URLs and its permissions list who can see it.
+ * A tag is put on the {@code url} of the document: the URL is added to the paths of the tag and the change of the
+ * tag field of every document of the URL is queued. The document is resolved with the caller's roles, so a document
+ * the caller cannot search is not found.
*
*
- * - {@code GET} lists the tags of the document that the caller can see, and whether the caller added each.
- * - {@code POST} with {@code {"name": "..."}} tags the document for the logged-in user: the URL is added to the
- * tag of the name and the user to its permissions, and a new tag is visible only to the user.
- * - {@code DELETE ?value=} removes the logged-in user from the permissions of the tag. The tag is
- * deleted when no permission is left.
+ * - {@code GET} lists the tags on the document that the caller can see ({@code tags}) and the caller's own tags
+ * that are not on it ({@code addable}).
+ * - {@code POST} with {@code {"id": "..."}} puts the caller's tag on the document; with {@code {"name": "..."}} it
+ * puts the caller's tag of the name on it, creating a private tag when there is none. A tag holds at most
+ * {@code user.tag.max.paths} URLs.
+ * - {@code DELETE .../tags/{id}} takes the caller's tag off the document.
*
- *
- * The document is resolved through the caller's roles, so a document the caller cannot search cannot be
- * tagged. Writes need a logged-in user; an access token does not stand in for one.
*/
-public class DocumentTagsHandler {
+public class DocumentTagsHandler extends AbstractTagHandler {
private static final Logger logger = LogManager.getLogger(DocumentTagsHandler.class);
- /** The request body has one short string. */
- private static final int MAX_BODY_BYTES = 1024;
-
/**
* Default constructor used by the DI container. The handler holds no per-request state.
*/
@@ -70,157 +69,159 @@ public DocumentTagsHandler() {
// no-op
}
- /** A failure that is answered with an error envelope. */
- private static class TagRequestException extends Exception {
- private static final long serialVersionUID = 1L;
-
- private final V2ErrorCode code;
-
- TagRequestException(final V2ErrorCode code, final String message) {
- super(message, null, false, false);
- this.code = code;
- }
- }
-
/**
- * Processes one {@code /api/v2/documents/{docId}/tags} request.
+ * Processes one {@code /api/v2/documents/{docId}/tags} or {@code /api/v2/documents/{docId}/tags/{id}} request.
*
* @param req the incoming HTTP request
* @param res the HTTP response to write to
- * @param docId the document id extracted from the URL path
+ * @param docId the document id from the URL path
+ * @param tagId the tag id of {@code .../tags/{id}}, or null for {@code .../tags}
* @throws IOException if writing the envelope fails
*/
- public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException {
+ public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId, final String tagId)
+ throws IOException {
final String method = req.getMethod() == null ? StringUtil.EMPTY : req.getMethod().toUpperCase(Locale.ROOT);
- if (!"GET".equals(method) && !"POST".equals(method) && !"DELETE".equals(method)) {
- res.setHeader("Allow", "GET, POST, DELETE");
+ final boolean collection = tagId == null;
+ if (collection ? !"GET".equals(method) && !"POST".equals(method) : !"DELETE".equals(method)) {
+ res.setHeader("Allow", collection ? "GET, POST" : "DELETE");
ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.METHOD_NOT_ALLOWED, "method not allowed");
return;
}
- final String context = "/api/v2/documents/" + docId + "/tags " + method;
+ if (!ComponentUtil.getV2DocIdValidator().isValid(docId)) {
+ ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.INVALID_REQUEST, "invalid doc_id");
+ return;
+ }
try {
- if (!ComponentUtil.getV2DocIdValidator().isValid(docId)) {
- throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "invalid doc_id");
- }
- final TagHelper tagHelper = ComponentUtil.getTagHelper();
- if (!tagHelper.isEnabled()) {
- throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "tag feature is not available");
- }
- final String userId = getUserBean().map(FessUserBean::getUserId).orElse(null);
- if (!"GET".equals(method) && StringUtil.isBlank(userId)) {
- throw new TagRequestException(V2ErrorCode.AUTH_REQUIRED, "login required");
- }
+ final String userId = checkRequest();
final String url = getDocumentUrl(docId);
-
final Map payload = new LinkedHashMap<>();
payload.put("doc_id", docId);
switch (method) {
- case "POST" -> payload.put("added", addTag(req, tagHelper, userId, url));
- case "DELETE" -> payload.put("removed", removeTag(req, tagHelper, userId));
+ case "POST" -> addTag(req, userId, url, payload);
+ case "DELETE" -> payload.put("removed", removeTag(tagId, userId, url));
default -> {
// GET lists the tags only
}
}
- payload.put("addable", StringUtil.isNotBlank(userId));
- payload.put("tags", buildTags(req, tagHelper, userId, url));
+ putTags(payload, userId, url);
ComponentUtil.getV2EnvelopeWriter().writeSuccess(res, payload);
} catch (final TagRequestException e) {
ComponentUtil.getV2EnvelopeWriter().writeError(res, e.code, e.getMessage());
} catch (final Exception e) {
- ComponentUtil.getV2EnvelopeWriter().writeInternalError(res, e, logger, context);
+ ComponentUtil.getV2EnvelopeWriter().writeInternalError(res, e, logger, "/api/v2/documents/" + docId + "/tags " + method);
}
}
/**
- * Validates the request body of a POST and tags the URL.
- *
- * @return true if the URL was tagged, false if the user had already tagged it
+ * Puts the caller's tag of the request body on the URL and adds {@code added} and {@code tag} to the payload.
*/
- private boolean addTag(final HttpServletRequest req, final TagHelper tagHelper, final String userId, final String url)
+ private void addTag(final HttpServletRequest req, final String userId, final String url, final Map payload)
throws TagRequestException, IOException {
- final Map body;
- try {
- body = ComponentUtil.getV2JsonBody().read(req, MAX_BODY_BYTES);
- } catch (final V2JsonBody.PayloadTooLargeException e) {
- throw new TagRequestException(V2ErrorCode.PAYLOAD_TOO_LARGE, e.getMessage());
- } catch (final V2JsonBody.UnsupportedMediaTypeException e) {
- throw new TagRequestException(V2ErrorCode.UNSUPPORTED_MEDIA_TYPE, e.getMessage());
- } catch (final V2JsonBody.MalformedJsonException e) {
- throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, e.getMessage());
- }
- final Object name = body.get("name");
- final String normalizedName = name instanceof final String nameValue ? tagHelper.normalizeName(nameValue) : null;
- if (normalizedName == null) {
- throw new TagRequestException(V2ErrorCode.INVALID_REQUEST,
- "invalid tag name: enter 1 to " + tagHelper.getNameMaxLength() + " characters");
- }
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final AddResult result = tagHelper.addTag(userId, url, normalizedName);
- switch (result) {
- case TOO_MANY_TAGS -> throw new TagRequestException(V2ErrorCode.INVALID_REQUEST,
- "too many tags: a document can have up to " + fessConfig.getUserTagMaxDocumentTagsAsInteger() + " tags");
- case TOO_MANY_LABELS -> throw new TagRequestException(V2ErrorCode.INVALID_REQUEST,
- "no more tags can be created: the number of labels reached page.labeltype.max.fetch.size");
- case ALREADY_ADDED -> {
- return false;
+ final Map body = readBody(req);
+ final String id;
+ if (body.get("id") instanceof final String value) {
+ id = value;
+ } else if (body.get("name") != null) {
+ final String name = toTagName(body.get("name"));
+ id = getTagTypeHelper().toId(getTagTypeHelper().toTagValue(name, userId));
+ if (!getTagTypeService().getTagType(id).isPresent()) {
+ final TagType created = createTag(name, userId, url);
+ if (created != null) {
+ payload.put("added", true);
+ payload.put("tag", toDocumentTag(created, userId));
+ return;
+ }
+ }
+ } else {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "name or id is required");
}
- default -> {
- updateDocuments(() -> tagHelper.addTagToDocuments(url, tagHelper.toValue(normalizedName)), url);
+ final boolean[] added = { false };
+ final int maxPaths = ComponentUtil.getFessConfig().getUserTagMaxPathsAsInteger();
+ final TagType tagType = updateTagType(id, userId, t -> {
+ final String[] paths = t.getPaths() == null ? new String[0] : t.getPaths();
+ if (Arrays.asList(paths).contains(url)) {
+ return false;
+ }
+ if (paths.length >= maxPaths) {
+ throw new TagRequestException(V2ErrorCode.INVALID_REQUEST,
+ "too many documents: a tag can be put on up to " + maxPaths + " documents");
+ }
+ final String[] newPaths = Arrays.copyOf(paths, paths.length + 1);
+ newPaths[paths.length] = url;
+ t.setPaths(newPaths);
+ added[0] = true;
return true;
+ });
+ if (added[0]) {
+ enqueue(TagChange.add(tagType.getTagValue(), url));
}
- }
+ payload.put("added", added[0]);
+ payload.put("tag", toDocumentTag(tagType, userId));
}
/**
- * Removes the user from the permissions of the tag of the DELETE request.
+ * Creates a private tag of the caller on the URL and queues the addition.
*
- * @return true if the user was removed
+ * @return the tag, or null when a concurrent request created the tag of the name first
*/
- private boolean removeTag(final HttpServletRequest req, final TagHelper tagHelper, final String userId) throws TagRequestException {
- final String value = req.getParameter("value");
- final LabelTypeItem item = StringUtil.isBlank(value) ? null
- : getTagItemList(req).stream().filter(i -> value.equals(i.getValue())).findFirst().orElse(null);
- if (item == null) {
- throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "invalid tag value");
- }
- if (!tagHelper.isMine(item, userId)) {
- throw new TagRequestException(V2ErrorCode.FORBIDDEN, "the tag was not added by the user");
- }
- if (tagHelper.removeTag(userId, value)) {
- updateDocuments(() -> tagHelper.removeTagFromDocuments(value), value);
+ private TagType createTag(final String name, final String userId, final String url) throws TagRequestException {
+ final TagType tagType = newTagType(name, userId, false, new String[] { url });
+ try {
+ getTagTypeService().insert(tagType);
+ } catch (final TagTypeConflictException e) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("The tag was created concurrently; adding the URL to it: id={}", tagType.getId());
+ }
+ return null;
}
- return true;
+ enqueue(TagChange.add(tagType.getTagValue(), url));
+ return tagType;
}
/**
- * Updates the tag field of the indexed documents. The label type is already stored, so a failure here is
- * logged and the next crawl or the label updater job brings the documents up to date.
+ * Takes the caller's tag off the URL.
+ *
+ * @return true if the tag was on the URL
*/
- private void updateDocuments(final Runnable update, final String target) {
- try {
- update.run();
- } catch (final Exception e) {
- logger.warn("Failed to update the tag field of the documents: target={}", target, e);
+ private boolean removeTag(final String tagId, final String userId, final String url) throws TagRequestException {
+ final boolean[] removed = { false };
+ final TagType tagType = updateTagType(tagId, userId, t -> {
+ final String[] paths = t.getPaths() == null ? new String[0] : t.getPaths();
+ final String[] newPaths = Arrays.stream(paths).filter(p -> !url.equals(p)).toArray(String[]::new);
+ if (newPaths.length == paths.length) {
+ return false;
+ }
+ t.setPaths(newPaths);
+ removed[0] = true;
+ return true;
+ });
+ if (removed[0]) {
+ enqueue(TagChange.remove(tagType.getTagValue(), url));
}
+ return removed[0];
}
- private List