001/**
002 * Copyright (C) 2006-2020 Talend Inc. - www.talend.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.talend.sdk.component.server.front;
017
018import static java.util.Collections.emptyMap;
019import static java.util.Optional.ofNullable;
020import static java.util.concurrent.CompletableFuture.completedFuture;
021import static java.util.stream.Collectors.joining;
022import static java.util.stream.Collectors.toList;
023import static java.util.stream.Collectors.toSet;
024
025import java.io.ByteArrayInputStream;
026import java.io.ByteArrayOutputStream;
027import java.io.IOException;
028import java.io.InputStream;
029import java.nio.charset.StandardCharsets;
030import java.security.Principal;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.List;
034import java.util.Map;
035import java.util.concurrent.CompletableFuture;
036import java.util.concurrent.CompletionStage;
037import java.util.concurrent.ExecutionException;
038import java.util.stream.Stream;
039
040import javax.annotation.PostConstruct;
041import javax.enterprise.context.ApplicationScoped;
042import javax.inject.Inject;
043import javax.json.bind.Jsonb;
044import javax.servlet.ServletContext;
045import javax.servlet.ServletException;
046import javax.servlet.http.HttpServletRequest;
047import javax.ws.rs.HttpMethod;
048import javax.ws.rs.WebApplicationException;
049import javax.ws.rs.core.Context;
050import javax.ws.rs.core.Response;
051import javax.ws.rs.core.UriInfo;
052
053import org.apache.cxf.Bus;
054import org.apache.cxf.transport.http.DestinationRegistry;
055import org.apache.cxf.transport.servlet.ServletController;
056import org.apache.cxf.transport.servlet.servicelist.ServiceListGeneratorServlet;
057import org.talend.sdk.component.server.api.BulkReadResource;
058import org.talend.sdk.component.server.front.cxf.CxfExtractor;
059import org.talend.sdk.component.server.front.memory.InMemoryRequest;
060import org.talend.sdk.component.server.front.memory.InMemoryResponse;
061import org.talend.sdk.component.server.front.memory.MemoryInputStream;
062import org.talend.sdk.component.server.front.memory.SimpleServletConfig;
063import org.talend.sdk.component.server.front.model.BulkRequests;
064import org.talend.sdk.component.server.front.model.BulkResponses;
065import org.talend.sdk.component.server.front.model.ErrorDictionary;
066import org.talend.sdk.component.server.front.model.error.ErrorPayload;
067import org.talend.sdk.component.server.front.security.web.EndpointSecurityService;
068import org.talend.sdk.component.server.service.qualifier.ComponentServer;
069
070import lombok.extern.slf4j.Slf4j;
071
072@Slf4j
073@ApplicationScoped
074public class BulkReadResourceImpl implements BulkReadResource {
075
076    private static final CompletableFuture[] EMPTY_PROMISES = new CompletableFuture[0];
077
078    @Inject
079    private CxfExtractor cxf;
080
081    @Inject
082    private Bus bus;
083
084    @Inject
085    @Context
086    private ServletContext servletContext;
087
088    @Inject
089    @Context
090    private HttpServletRequest httpServletRequest;
091
092    @Inject
093    @Context
094    private UriInfo uriInfo;
095
096    @Inject
097    @Context
098    private HttpServletRequest request;
099
100    @Inject
101    @ComponentServer
102    private Jsonb defaultMapper;
103
104    @Inject
105    private EndpointSecurityService endpointSecurityService;
106
107    private ServletController controller;
108
109    private final String appPrefix = "/api/v1";
110
111    private final Collection<String> blacklisted =
112            Stream.of(appPrefix + "/component/icon/", appPrefix + "/component/dependency/").collect(toSet());
113
114    private final BulkResponses.Result forbiddenInBulkModeResponse =
115            new BulkResponses.Result(Response.Status.FORBIDDEN.getStatusCode(), emptyMap(),
116                    "{\"code\":\"UNAUTHORIZED\",\"description\":\"Forbidden endpoint in bulk mode.\"}"
117                            .getBytes(StandardCharsets.UTF_8));
118
119    private final BulkResponses.Result forbiddenResponse =
120            new BulkResponses.Result(Response.Status.FORBIDDEN.getStatusCode(), emptyMap(),
121                    "{\"code\":\"UNAUTHORIZED\",\"description\":\"Secured endpoint, ensure to pass the right token.\"}"
122                            .getBytes(StandardCharsets.UTF_8));
123
124    private final BulkResponses.Result invalidResponse =
125            new BulkResponses.Result(Response.Status.BAD_REQUEST.getStatusCode(), emptyMap(),
126                    "{\"code\":\"UNEXPECTED\",\"description\":\"unknownEndpoint.\"}".getBytes(StandardCharsets.UTF_8));
127
128    @PostConstruct
129    private void init() {
130        final DestinationRegistry registry = cxf.getRegistry();
131        controller = new ServletController(registry,
132                new SimpleServletConfig(servletContext, "Talend Component Kit Bulk Transport"),
133                new ServiceListGeneratorServlet(registry, bus));
134    }
135
136    @Override
137    public CompletionStage<BulkResponses> bulk(final BulkRequests requests) {
138        final Collection<CompletableFuture<BulkResponses.Result>> responses =
139                ofNullable(requests.getRequests()).map(Collection::stream).orElseGet(Stream::empty).map(request -> {
140                    if (isBlacklisted(request)) {
141                        return completedFuture(forbiddenInBulkModeResponse);
142                    }
143                    if ("/api/v1/environment".equals(request.getPath())
144                            && !endpointSecurityService.isAllowed(httpServletRequest)) {
145                        return completedFuture(forbiddenResponse);
146                    }
147                    if (request.getPath() == null || !request.getPath().startsWith(appPrefix)
148                            || request.getPath().contains("?")) {
149                        return completedFuture(invalidResponse);
150                    }
151                    return doExecute(request, uriInfo);
152                }).collect(toList());
153        return CompletableFuture
154                .allOf(responses.toArray(EMPTY_PROMISES))
155                .handle((ignored, error) -> new BulkResponses(responses.stream().map(it -> {
156                    try {
157                        return it.get();
158                    } catch (final InterruptedException e) {
159                        Thread.currentThread().interrupt();
160                        throw new IllegalStateException(e);
161                    } catch (final ExecutionException e) {
162                        throw new WebApplicationException(Response
163                                .serverError()
164                                .entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, e.getMessage()))
165                                .build());
166                    }
167                }).collect(toList())));
168    }
169
170    private boolean isBlacklisted(final BulkRequests.Request request) {
171        return blacklisted.stream().anyMatch(it -> request.getPath() == null || request.getPath().startsWith(it));
172    }
173
174    private CompletableFuture<BulkResponses.Result> doExecute(final BulkRequests.Request inputRequest,
175            final UriInfo info) {
176        final Map<String, List<String>> headers =
177                ofNullable(inputRequest.getHeaders()).orElseGet(Collections::emptyMap);
178        final String path = ofNullable(inputRequest.getPath()).map(it -> it.substring(appPrefix.length())).orElse("/");
179
180        // theorically we should encode these params but should be ok this way for now - due to the param we can accept
181        final String queryString = ofNullable(inputRequest.getQueryParameters())
182                .map(Map::entrySet)
183                .map(Collection::stream)
184                .orElseGet(Stream::empty)
185                .flatMap(it -> ofNullable(it.getValue())
186                        .map(Collection::stream)
187                        .orElseGet(Stream::empty)
188                        .map(value -> it.getKey() + '=' + value))
189                .collect(joining("&"));
190
191        final int port = info.getBaseUri().getPort();
192        final Principal userPrincipal = request.getUserPrincipal(); // this is ap proxy so ready it early
193        final InMemoryRequest request = new InMemoryRequest(ofNullable(inputRequest.getVerb()).orElse(HttpMethod.GET),
194                headers, path, appPrefix + path, appPrefix, queryString, port < 0 ? 8080 : port, servletContext,
195                new MemoryInputStream(ofNullable(inputRequest.getPayload())
196                        .map(it -> it.getBytes(StandardCharsets.UTF_8))
197                        .map(ByteArrayInputStream::new)
198                        .map(InputStream.class::cast)
199                        .orElse(null)),
200                () -> userPrincipal, controller);
201        final BulkResponses.Result result = new BulkResponses.Result();
202        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
203        final CompletableFuture<BulkResponses.Result> promise = new CompletableFuture<>();
204        final InMemoryResponse response = new InMemoryResponse(() -> true, () -> {
205            result.setResponse(outputStream.toByteArray());
206            promise.complete(result);
207        }, bytes -> {
208            try {
209                outputStream.write(bytes);
210            } catch (final IOException e) {
211                throw new IllegalStateException(e);
212            }
213        }, (status, responseHeaders) -> {
214            result.setStatus(status);
215            result.setHeaders(headers);
216            return "";
217        });
218        request.setResponse(response);
219        try {
220            controller.invoke(request, response);
221        } catch (final ServletException e) {
222            result.setStatus(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode());
223            result
224                    .setResponse(defaultMapper
225                            .toJson(new ErrorPayload(ErrorDictionary.UNEXPECTED, e.getMessage()))
226                            .getBytes(StandardCharsets.UTF_8));
227            promise.complete(result);
228            throw new IllegalStateException(e);
229        }
230        return promise;
231    }
232}