001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2016-2018 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.http;
020
021import java.io.IOException;
022import java.net.MalformedURLException;
023import java.net.URI;
024import java.nio.file.FileSystem;
025import java.nio.file.FileSystemNotFoundException;
026import java.nio.file.Files;
027import java.nio.file.Path;
028import java.nio.file.Paths;
029import java.nio.file.StandardOpenOption;
030import java.text.ParseException;
031import java.time.Instant;
032import java.time.temporal.ChronoField;
033import java.util.Arrays;
034import java.util.Optional;
035import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
036import org.jdrupes.httpcodec.protocols.http.HttpField;
037import org.jdrupes.httpcodec.protocols.http.HttpResponse;
038import org.jdrupes.httpcodec.types.Converters;
039import org.jdrupes.httpcodec.types.MediaType;
040import org.jgrapes.core.Channel;
041import org.jgrapes.core.Component;
042import org.jgrapes.http.ResponseCreationSupport.MaxAgeCalculator;
043import org.jgrapes.http.annotation.RequestHandler;
044import org.jgrapes.http.events.Request;
045import org.jgrapes.http.events.Response;
046import org.jgrapes.io.IOSubchannel;
047import org.jgrapes.io.events.StreamFile;
048
049/**
050 * A dispatcher for requests for static content, usually files.
051 */
052public class StaticContentDispatcher extends Component {
053
054    private ResourcePattern resourcePattern;
055    private URI contentRoot;
056    private Path contentDirectory;
057    private MaxAgeCalculator maxAgeCalculator
058        = (request, mediaType) -> 365 * 24 * 3600;
059
060    /**
061     * Creates new dispatcher that tries to fulfill requests matching 
062     * the given resource pattern from the given content root.
063     * 
064     * An attempt is made to convert the content root to a {@link Path}
065     * in a {@link FileSystem}. If this fails, the content root is
066     * used as a URL against which requests are resolved and data
067     * is obtained by open an input stream from the resulting URL.
068     * In the latter case modification times aren't available. 
069     * 
070     * @param componentChannel this component's channel
071     * @param resourcePattern the pattern that requests must match 
072     * in order to be handled by this component 
073     * (see {@link ResourcePattern})
074     * @param contentRoot the location with content to serve 
075     * @see Component#Component(Channel)
076     */
077    public StaticContentDispatcher(Channel componentChannel,
078            String resourcePattern, URI contentRoot) {
079        super(componentChannel);
080        try {
081            this.resourcePattern = new ResourcePattern(resourcePattern);
082        } catch (ParseException e) {
083            throw new IllegalArgumentException(e);
084        }
085        try {
086            this.contentDirectory = Paths.get(contentRoot);
087        } catch (FileSystemNotFoundException e) {
088            this.contentRoot = contentRoot;
089        }
090        RequestHandler.Evaluator.add(this, "onGet", resourcePattern);
091    }
092
093    /**
094     * Creates a new component base with its channel set to
095     * itself.
096     * 
097     * @param resourcePattern the pattern that requests must match with to 
098     * be handled by this component 
099     * (see {@link ResourcePattern#matches(String, java.net.URI)})
100     * @param contentRoot the location with content to serve 
101     * @see Component#Component()
102     */
103    public StaticContentDispatcher(String resourcePattern, URI contentRoot) {
104        this(Channel.SELF, resourcePattern, contentRoot);
105    }
106
107    /**
108     * @return the maxAgeCalculator
109     */
110    public MaxAgeCalculator maxAgeCalculator() {
111        return maxAgeCalculator;
112    }
113
114    /**
115     * Sets the {@link MaxAgeCalculator} for generating the `Cache-Control` 
116     * (`max-age`) header of the response. The default max age calculator 
117     * used simply returns a max age of one year, since this component
118     * is intended to serve static content.
119     * 
120     * @param maxAgeCalculator the maxAgeCalculator to set
121     */
122    public void setMaxAgeCalculator(MaxAgeCalculator maxAgeCalculator) {
123        this.maxAgeCalculator = maxAgeCalculator;
124    }
125
126    /**
127     * Handles a `GET` request.
128     *
129     * @param event the event
130     * @param channel the channel
131     * @throws ParseException the parse exception
132     * @throws IOException Signals that an I/O exception has occurred.
133     */
134    @RequestHandler(dynamic = true)
135    public void onGet(Request.In.Get event, IOSubchannel channel)
136            throws ParseException, IOException {
137        if (event.fulfilled()) {
138            return;
139        }
140        int prefixSegs = resourcePattern.matches(event.requestUri());
141        if (prefixSegs < 0) {
142            return;
143        }
144        if (contentDirectory == null) {
145            getFromUri(event, channel, prefixSegs);
146        } else {
147            getFromFileSystem(event, channel, prefixSegs);
148        }
149    }
150
151    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
152    private boolean getFromFileSystem(Request.In.Get event,
153            IOSubchannel channel, int prefixSegs)
154            throws IOException, ParseException {
155        // Final wrapper for usage in closure
156        final Path[] assembly = { contentDirectory };
157        Arrays.stream(event.requestUri().getPath().split("/"))
158            .skip(prefixSegs + 1)
159            .forEach(seg -> assembly[0] = assembly[0].resolve(seg));
160        Path resourcePath = assembly[0];
161        if (Files.isDirectory(resourcePath)) {
162            Path indexPath = resourcePath.resolve("index.html");
163            if (Files.isReadable(indexPath)) {
164                resourcePath = indexPath;
165            } else {
166                return false;
167            }
168        }
169        if (!Files.isReadable(resourcePath)) {
170            return false;
171        }
172
173        // Get content type
174        HttpResponse response = event.httpRequest().response().get();
175        MediaType mediaType = HttpResponse.contentType(resourcePath.toUri());
176
177        // Derive max-age
178        ResponseCreationSupport.setMaxAge(
179            response, maxAgeCalculator.maxAge(event.httpRequest(), mediaType));
180
181        // Check if sending is really required.
182        Instant lastModified = Files.getLastModifiedTime(resourcePath)
183            .toInstant().with(ChronoField.NANO_OF_SECOND, 0);
184        Optional<Instant> modifiedSince = event.httpRequest()
185            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
186        event.setResult(true);
187        event.stop();
188        if (modifiedSince.isPresent()
189            && !lastModified.isAfter(modifiedSince.get())) {
190            response.setStatus(HttpStatus.NOT_MODIFIED);
191            response.setField(HttpField.LAST_MODIFIED, lastModified);
192            channel.respond(new Response(response));
193        } else {
194            response.setContentType(mediaType);
195            response.setStatus(HttpStatus.OK);
196            response.setField(HttpField.LAST_MODIFIED, lastModified);
197            channel.respond(new Response(response));
198            fire(new StreamFile(resourcePath, StandardOpenOption.READ),
199                channel);
200        }
201        return true;
202    }
203
204    private boolean getFromUri(Request.In.Get event, IOSubchannel channel,
205            int prefixSegs) throws ParseException {
206        return ResponseCreationSupport.sendStaticContent(
207            event, channel, path -> {
208                try {
209                    return contentRoot.resolve(
210                        ResourcePattern.removeSegments(
211                            path, prefixSegs + 1))
212                        .toURL();
213                } catch (MalformedURLException e) {
214                    throw new IllegalArgumentException(e);
215                }
216            }, maxAgeCalculator);
217    }
218
219}