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        int prefixSegs = resourcePattern.matches(event.requestUri());
138        if (prefixSegs < 0) {
139            return;
140        }
141        if (contentDirectory == null) {
142            getFromUri(event, channel, prefixSegs);
143        } else {
144            getFromFileSystem(event, channel, prefixSegs);
145        }
146    }
147
148    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
149    private boolean getFromFileSystem(Request.In.Get event,
150            IOSubchannel channel, int prefixSegs)
151            throws IOException, ParseException {
152        // Final wrapper for usage in closure
153        final Path[] assembly = { contentDirectory };
154        Arrays.stream(event.requestUri().getPath().split("/"))
155            .skip(prefixSegs + 1)
156            .forEach(seg -> assembly[0] = assembly[0].resolve(seg));
157        Path resourcePath = assembly[0];
158        if (Files.isDirectory(resourcePath)) {
159            Path indexPath = resourcePath.resolve("index.html");
160            if (Files.isReadable(indexPath)) {
161                resourcePath = indexPath;
162            } else {
163                return false;
164            }
165        }
166        if (!Files.isReadable(resourcePath)) {
167            return false;
168        }
169
170        // Get content type
171        HttpResponse response = event.httpRequest().response().get();
172        MediaType mediaType = HttpResponse.contentType(resourcePath.toUri());
173
174        // Derive max-age
175        ResponseCreationSupport.setMaxAge(
176            response, maxAgeCalculator.maxAge(event.httpRequest(), mediaType));
177
178        // Check if sending is really required.
179        Instant lastModified = Files.getLastModifiedTime(resourcePath)
180            .toInstant().with(ChronoField.NANO_OF_SECOND, 0);
181        Optional<Instant> modifiedSince = event.httpRequest()
182            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
183        event.setResult(true);
184        event.stop();
185        if (modifiedSince.isPresent()
186            && !lastModified.isAfter(modifiedSince.get())) {
187            response.setStatus(HttpStatus.NOT_MODIFIED);
188            response.setField(HttpField.LAST_MODIFIED, lastModified);
189            channel.respond(new Response(response));
190        } else {
191            response.setContentType(mediaType);
192            response.setStatus(HttpStatus.OK);
193            response.setField(HttpField.LAST_MODIFIED, lastModified);
194            channel.respond(new Response(response));
195            fire(new StreamFile(resourcePath, StandardOpenOption.READ),
196                channel);
197        }
198        return true;
199    }
200
201    private boolean getFromUri(Request.In.Get event, IOSubchannel channel,
202            int prefixSegs) throws ParseException {
203        return ResponseCreationSupport.sendStaticContent(
204            event, channel, path -> {
205                try {
206                    return contentRoot.resolve(
207                        ResourcePattern.removeSegments(
208                            path, prefixSegs + 1))
209                        .toURL();
210                } catch (MalformedURLException e) {
211                    throw new IllegalArgumentException(e);
212                }
213            }, maxAgeCalculator);
214    }
215
216}