From 1e975a1c6d1242ea69b6cc22e44173b643da8bf5 Mon Sep 17 00:00:00 2001 From: Chris Malloy Date: Thu, 26 Sep 2024 15:16:07 -0300 Subject: [PATCH] Fixed permissions for tag urls --- README.md | 26 ++- src/main/java/jasper/domain/proj/Tag.java | 23 ++ src/main/java/jasper/security/Auth.java | 12 +- .../java/jasper/service/TaggingService.java | 40 +++- .../jasper/web/rest/TaggingController.java | 35 ++- src/main/resources/swagger/api.yml | 215 ++++++++++-------- 6 files changed, 246 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index adedb38c..b5ac0273 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Knowledge Management Server [![Build & Test](https://github.com/cjmalloy/jasper/actions/workflows/docker-image.yml/badge.svg)](https://github.com/cjmalloy/jasper/actions/workflows/docker-image.yml) -[![SwaggerHub](https://img.shields.io/badge/SwaggerHub-1.2.29-brightgreen)](https://app.swaggerhub.com/apis/cjmalloy/Jasper) +[![SwaggerHub](https://img.shields.io/badge/SwaggerHub-1.2.30-brightgreen)](https://app.swaggerhub.com/apis/cjmalloy/Jasper) [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/jasper)](https://artifacthub.io/packages/helm/jasper/jasper) ## Quickstart @@ -529,6 +529,29 @@ The tag permissions are stored in the User entities: * Can write ref with tag * Can edit tag Ext +### Special URL Schemas + +### Cache +URLs that have the `cache:` scheme represent items stored in a file cache. +Most URLs with a resource in a file cache have a standard `https:` scheme, +as they are just a cache of a resource that exists elsewhere. +When a file is pushed into the cache (such as a generated thumbnail), it is +generated a random `cache:` URL. + +#### Tag URLs +URLs that point to a tag, such as `tag:/history` ignore regular tagging access rules. +Instead, you can access this Ref if you can access the tag it points to. + +#### User URLs +URLs that point to a user tag, such as `tag:/+user/chris` are always owned by the user. +These specials URLs can also be used to store per-plugin config data, +such as `tag:/+user/chris?url=tag:/plugin/kanban`. +Visibility of plugin setting can be set on a per-user, per-plugin basis. +For convenience, the user URL is used if a blank URL is passed to the tagging response controller. +This allows you to quickly ensure settings are initialized and fetch / edit Ref plugins and tags to read settings. +If a tag are passed, for example `plugin/kanban`, the default is the kanban user settings Ref: `tag:/+user/chris?url=tag:/plugin/kanban`. +If a blank URL and a blank tag are passed, the default is the generic user settings Ref: `tag:/+user/chris`. + ### Special Tags Some public tags have special significance: * `public`: everyone can read @@ -575,7 +598,6 @@ Jasper generates the following metadata in Refs: * Obsolete: flag set if another origin contains the newest version of this Ref ## Server Scripting - When the `scripts` profile is active, scripts may be attached to Refs with either the `plugin/delta` tag or the `plugin/script` tag. Only admin users may install scripts and they run with very few guardrails. A regular user may invoke the script diff --git a/src/main/java/jasper/domain/proj/Tag.java b/src/main/java/jasper/domain/proj/Tag.java index 148fdae4..5ac6efcb 100644 --- a/src/main/java/jasper/domain/proj/Tag.java +++ b/src/main/java/jasper/domain/proj/Tag.java @@ -21,10 +21,33 @@ default String getQualifiedTag() { return getTag() + getOrigin(); } + static boolean userUrl(String url) { + return + userUrl(url, "+user") || url.startsWith("tag:/+user/") || + userUrl(url, "_user") || url.startsWith("tag:/_user/"); + } + + static boolean userUrl(String url, String user) { + return url.equals("tag:/" + user) || + url.startsWith("tag:/" + user + "?") || + url.startsWith("tag:/" + user + "/"); + } + static String urlForUser(String url, String user) { + if (isBlank(url)) return "tag:/" + user; return "tag:/" + user + "?url=" + url; } + static boolean tagUrl(String url) { + return url.startsWith("tag:/"); + } + + static String urlToTag(String url) { + var tag = url.substring("tag:/".length()); + if (tag.contains("?")) return tag.substring(0, tag.indexOf("?")); + return tag; + } + static boolean isPublicTag(String tag) { if (isBlank(tag)) return false; return !tag.startsWith("_") && !tag.startsWith("+"); diff --git a/src/main/java/jasper/security/Auth.java b/src/main/java/jasper/security/Auth.java index c3093e8f..b45dfc41 100644 --- a/src/main/java/jasper/security/Auth.java +++ b/src/main/java/jasper/security/Auth.java @@ -49,6 +49,9 @@ import static io.jsonwebtoken.Jwts.claims; import static jasper.config.JacksonConfiguration.dump; import static jasper.domain.proj.HasOrigin.isSubOrigin; +import static jasper.domain.proj.Tag.tagUrl; +import static jasper.domain.proj.Tag.urlToTag; +import static jasper.domain.proj.Tag.userUrl; import static jasper.repository.spec.OriginSpec.isOrigin; import static jasper.repository.spec.QualifiedTag.qt; import static jasper.repository.spec.QualifiedTag.qtList; @@ -264,6 +267,10 @@ public boolean canReadRef(HasTags ref) { if (hasRole(MOD)) return true; // Min Role if (!minRole()) return false; + // User URL + if (userUrl(ref.getUrl())) return isLoggedIn() && userUrl(ref.getUrl(), getUserTag().tag); + // Tag URLs + if (tagUrl(ref.getUrl())) return canReadTag(urlToTag(ref.getUrl()) + ref.getOrigin()); // No tags, only mods can read if (ref.getTags() == null) return false; // Add the ref's origin to its tag list @@ -312,9 +319,12 @@ public boolean canWriteRef(String url, String origin) { if (!minRole()) return false; // Minimum role for writing if (!minWriteRole()) return false; + // User URL + if (userUrl(url)) return isLoggedIn() && userUrl(url, getUserTag().tag); + // Tag URLs + if (tagUrl(url)) return canWriteTag(urlToTag(url) + origin); var maybeExisting = refRepository.findOneByUrlAndOrigin(url, origin); if (maybeExisting.isEmpty()) { - if (url.startsWith("tag:/")) return hasRole(MOD) || url.startsWith("tag:/" + getUserTag().tag + "?"); // If we're creating, simply having the role USER is enough return hasRole(USER); } diff --git a/src/main/java/jasper/service/TaggingService.java b/src/main/java/jasper/service/TaggingService.java index 3f0779e3..969ef554 100644 --- a/src/main/java/jasper/service/TaggingService.java +++ b/src/main/java/jasper/service/TaggingService.java @@ -7,6 +7,8 @@ import jasper.errors.NotFoundException; import jasper.repository.RefRepository; import jasper.security.Auth; +import jasper.service.dto.DtoMapper; +import jasper.service.dto.RefDto; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -19,6 +21,8 @@ import java.util.List; import static jasper.domain.proj.Tag.urlForUser; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; @Service public class TaggingService { @@ -33,6 +37,9 @@ public class TaggingService { @Autowired Auth auth; + @Autowired + DtoMapper mapper; + @PreAuthorize("@auth.canTag(#tag, #url, #origin)") @Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true) public Instant create(String tag, String url, String origin) { @@ -84,11 +91,17 @@ public Instant tag(List tags, String url, String origin) { return ref.getModified(); } + @PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)") + @Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true) + public RefDto getResponse(String tag, String url) { + return mapper.domainToDto(getRef(url, tag)); + } + @PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)") @Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true) public void createResponse(String tag, String url) { - var ref = getResponseRef(url); - if (!ref.getTags().contains(tag)) { + var ref = getRef(url, tag); + if (isNotBlank(tag) && !ref.getTags().contains(tag)) { ref.getTags().add(tag); } ingest.update(ref, false); @@ -97,7 +110,7 @@ public void createResponse(String tag, String url) { @PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)") @Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true) public void deleteResponse(String tag, String url) { - var ref = getResponseRef(url); + var ref = getRef(url, tag); ref.removeTag(tag); ingest.update(ref, true); } @@ -105,15 +118,30 @@ public void deleteResponse(String tag, String url) { @PreAuthorize("@auth.hasRole('USER') and @auth.canAddTags(@auth.tagPatch(#tags))") @Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true) public void respond(List tags, String url) { - var ref = getResponseRef(url); + var ref = getRef(url, null); for (var tag : tags) ref.addTag(tag); ingest.update(ref, true); } + private Ref getRef(String url, String tag) { + if (isNotBlank(url)) return getResponseRef(url); + if (isNotBlank(tag)) return getTagRef(tag); + return getUserRef(); + } + + private Ref getUserRef() { + return getResponseRef(null); + } + + private Ref getTagRef(String tag) { + return getResponseRef("tag:/" + tag); + } + private Ref getResponseRef(String url) { + if (auth.getUserTag() == null || isBlank(auth.getUserTag().tag)) throw new NotFoundException("User URL"); var userUrl = urlForUser(url, auth.getUserTag().tag); return refRepository.findOneByUrlAndOrigin(userUrl, auth.getOrigin()).map(ref -> { - if (ref.getSources() == null || !ref.getSources().contains(url)) ref.setSources(new ArrayList<>(List.of(url))); + if (isNotBlank(url) && (ref.getSources() == null || !ref.getSources().contains(url))) ref.setSources(new ArrayList<>(List.of(url))); if (ref.getTags() == null || ref.getTags().contains("plugin/deleted")) { ref.setTags(new ArrayList<>(List.of("internal", auth.getUserTag().tag))); } @@ -123,7 +151,7 @@ private Ref getResponseRef(String url) { var ref = new Ref(); ref.setUrl(userUrl); ref.setOrigin(auth.getOrigin()); - ref.setSources(new ArrayList<>(List.of(url))); + if (isNotBlank(url)) ref.setSources(new ArrayList<>(List.of(url))); ref.setTags(new ArrayList<>(List.of("internal", auth.getUserTag().tag))); ingest.create(ref, false); return ref; diff --git a/src/main/java/jasper/web/rest/TaggingController.java b/src/main/java/jasper/web/rest/TaggingController.java index fe94f1ae..e493aadc 100644 --- a/src/main/java/jasper/web/rest/TaggingController.java +++ b/src/main/java/jasper/web/rest/TaggingController.java @@ -4,21 +4,26 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jasper.component.HttpCache; import jasper.domain.Ref; import jasper.domain.proj.HasOrigin; import jasper.domain.proj.Tag; import jasper.service.TaggingService; +import jasper.service.dto.RefDto; import org.hibernate.validator.constraints.Length; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.WebRequest; import javax.validation.constraints.Pattern; import java.time.Instant; @@ -36,15 +41,18 @@ @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), - @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) public class TaggingController { @Autowired TaggingService taggingService; + @Autowired + HttpCache httpCache; + @ApiResponses({ @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @PostMapping Instant createTags( @@ -57,6 +65,7 @@ Instant createTags( @ApiResponses({ @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @DeleteMapping Instant deleteTags( @@ -69,6 +78,7 @@ Instant deleteTags( @ApiResponses({ @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @PatchMapping Instant patchTags( @@ -79,38 +89,53 @@ Instant patchTags( return taggingService.tag(tags, url, origin); } + @ApiResponses({ + @ApiResponse(responseCode = "200"), + }) + @GetMapping("response") + HttpEntity getResponse( + WebRequest request, + @RequestParam(defaultValue = "") @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag, + @RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url + ) { + return httpCache.ifNotModified(request, taggingService.getResponse(tag, url)); + } + @ApiResponses({ @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @PostMapping("response") @ResponseStatus(HttpStatus.CREATED) void createResponse( - @RequestParam @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag, - @RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url + @RequestParam(defaultValue = "") @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag, + @RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url ) { taggingService.createResponse(tag, url); } @ApiResponses({ @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @DeleteMapping("response") @ResponseStatus(HttpStatus.NO_CONTENT) void deleteResponse( @RequestParam @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag, - @RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url + @RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url ) { taggingService.deleteResponse(tag, url); } @ApiResponses({ @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))), }) @PatchMapping("response") @ResponseStatus(HttpStatus.NO_CONTENT) void patchResponse( @RequestParam List<@Length(max = TAG_LEN + 1) @Pattern(regexp = Tag.ADD_REMOVE_REGEX) String> tags, - @RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url + @RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url ) { taggingService.respond(tags, url); } diff --git a/src/main/resources/swagger/api.yml b/src/main/resources/swagger/api.yml index d3429114..969fd0d3 100644 --- a/src/main/resources/swagger/api.yml +++ b/src/main/resources/swagger/api.yml @@ -2,13 +2,13 @@ openapi: 3.0.3 info: title: Jasper Protocol description: REST API for the Jasper Protocol - version: 1.2.29 + version: 1.2.30 license: name: MIT License url: 'https://github.com/cjmalloy/jasper/blob/master/LICENSE' servers: - description: SwaggerHub API Auto Mocking - url: https://virtserver.swaggerhub.com/cjmalloy/Jasper/1.2.29 + url: https://virtserver.swaggerhub.com/cjmalloy/Jasper/1.2.30 paths: /api/v1/ref: get: @@ -564,108 +564,40 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' - /api/v1/proxy: + /api/v1/tags/response: get: tags: - - Proxy - operationId: fetch - description: Immediately scrape a webpage and return the contents. Will check and update cache. + - Tagging + operationId: getResponse parameters: + - name: tag + in: query + required: false + schema: + $ref: '#/components/schemas/tag' - name: url in: query - required: true + required: false schema: $ref: '#/components/schemas/url' - - name: thumbnail + - name: origin in: query required: false schema: - type: boolean - default: false + $ref: '#/components/schemas/origin' responses: '200': description: OK content: '*/*': schema: - type: string - format: binary + $ref: '#/components/schemas/Ref' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '500': - description: Error fetching - content: - '*/*': - schema: - $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' - post: - tags: - - Proxy - operationId: save - description: Uploads a file to the cache and returns a generated Ref to access it with. - parameters: - - name: url - in: query - required: true - schema: - $ref: '#/components/schemas/url' - requestBody: - content: - '*/*': - schema: - format: binary - responses: - '201': - description: Created - content: - '*/*': - schema: - $ref: '#/components/schemas/Ref' - '500': - description: Error caching - content: - '*/*': - schema: - $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' - delete: - tags: - - Proxy - operationId: clearDeleted - responses: - '204': - description: No Content - '500': - description: Error clearing deleted - content: - '*/*': - schema: - $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' - /api/v1/proxy/prefetch: - get: - tags: - - Proxy - operationId: preFetch - description: Immediately scrape a webpage if it is not in the cache. - parameters: - - name: url - in: query - required: true - schema: - $ref: '#/components/schemas/url' - responses: - '200': - description: Mime type scraped - content: - '*/*': - schema: - type: string - '500': - description: Error fetching - content: - '*/*': - schema: - $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' - /api/v1/tags/response: post: tags: - Tagging @@ -673,12 +605,12 @@ paths: parameters: - name: tag in: query - required: true + required: false schema: $ref: '#/components/schemas/tag' - name: url in: query - required: true + required: false schema: $ref: '#/components/schemas/url' - name: origin @@ -704,12 +636,12 @@ paths: parameters: - name: tag in: query - required: true + required: false schema: $ref: '#/components/schemas/tag' - name: url in: query - required: true + required: false schema: $ref: '#/components/schemas/url' - name: origin @@ -742,7 +674,7 @@ paths: $ref: '#/components/schemas/tagPatch' - name: url in: query - required: true + required: false schema: $ref: '#/components/schemas/url' responses: @@ -756,6 +688,107 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + /api/v1/proxy: + get: + tags: + - Proxy + operationId: fetch + description: Immediately scrape a webpage and return the contents. Will check and update cache. + parameters: + - name: url + in: query + required: true + schema: + $ref: '#/components/schemas/url' + - name: thumbnail + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: OK + content: + '*/*': + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Error fetching + content: + '*/*': + schema: + $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' + post: + tags: + - Proxy + operationId: save + description: Uploads a file to the cache and returns a generated Ref to access it with. + parameters: + - name: url + in: query + required: true + schema: + $ref: '#/components/schemas/url' + requestBody: + content: + '*/*': + schema: + format: binary + responses: + '201': + description: Created + content: + '*/*': + schema: + $ref: '#/components/schemas/Ref' + '500': + description: Error caching + content: + '*/*': + schema: + $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' + delete: + tags: + - Proxy + operationId: clearDeleted + responses: + '204': + description: No Content + '500': + description: Error clearing deleted + content: + '*/*': + schema: + $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' + /api/v1/proxy/prefetch: + get: + tags: + - Proxy + operationId: preFetch + description: Immediately scrape a webpage if it is not in the cache. + parameters: + - name: url + in: query + required: true + schema: + $ref: '#/components/schemas/url' + responses: + '200': + description: Mime type scraped + content: + '*/*': + schema: + type: string + '500': + description: Error fetching + content: + '*/*': + schema: + $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem' /api/v1/scrape/feed: post: tags: