diff --git a/README.md b/README.md index f21c15e..6cd7a72 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,11 @@ unnecessary features, or they didn't have all the features I wanted. # Screenshot ![Screenshot](./screenshot.png) -# Planned features -- An actual name -- Some form of authentication -- Input validation (on client and server) +# Planned features for 1.0 (in order of importance - Deleting links using API and frontend -- Code cleanup - Better deduplication +- Code cleanup +- An actual name - Official Docker Hub image # Usage @@ -38,7 +36,7 @@ git clone https://github.com/draganczukp/url ``` ## Building from source Gradle 6.x.x and JDK 11 are required. Other versions are not tested -1. Build the `.jar` file +### 1. Build the `.jar` file ``` gradle build --no-daemon ``` @@ -46,27 +44,36 @@ The `--no-daemon` option means that gradle should exit as soon as the build is finished. Without it, gradle would still be running in the background in order to speed up future builds. -2. Run it +### 2. Set environment variables +```bash +export username= +export password= +export file.location= # opitonal +``` + +### 3. Run it ``` java -jar build/libs/url.jar ``` -3. Navigate to `http://localhost:4567` in your browser, add links as you wish. +### 4. Navigate to `http://localhost:4567` in your browser, add links as you wish. ## Running with docker ### `docker run` method 1. Build the image ``` -docker build . -t url:1.0 +docker build . -t url:latest ``` 2. Run the image ``` -docker run -p 4567:4567 -d url:1.0 +docker run -p 4567:4567 -d url:latest ``` 2.a Make the CSV file available to host ``` touch ./urls.csv docker run -p 4567:4567 \ -e file.location=/urls.csv \ + -e username="username" + -e password="password" -v ./urls.csv:/urls.csv \ -d url:1.0 ``` diff --git a/build.gradle b/build.gradle index 609d499..58e0dae 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ jar { dependencies { compile "com.sparkjava:spark-core:2.8.0" + implementation 'com.qmetric:spark-authentication:1.4' } application { diff --git a/docker-compose.yml b/docker-compose.yml index 6e38353..eabed78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,11 @@ services: # TODO: Publish to docker hub build: context: . - # ports: - # - 4567:4567 + container_name: url environment: - file.location=/urls.csv + - username=${URL_LOGIN} + - password=${URL_PASSWORD} volumes: - ./urls.csv:/urls.csv networks: @@ -30,4 +31,3 @@ services: networks: proxy: external: true - diff --git a/src/main/java/tk/draganczuk/url/App.java b/src/main/java/tk/draganczuk/url/App.java index 24bff3e..603e690 100644 --- a/src/main/java/tk/draganczuk/url/App.java +++ b/src/main/java/tk/draganczuk/url/App.java @@ -1,5 +1,7 @@ package tk.draganczuk.url; +import spark.Filter; + import static spark.Spark.*; public class App { @@ -17,13 +19,26 @@ public class App { port(Integer.parseInt(System.getProperty("port", "4567"))); + // Add GZIP compression + after(Filters::addGZIP); + + // Authenticate + Filter authFilter = Filters.createAuthFilter(); + before("/index.html", authFilter); + get("/", (req, res) -> { res.redirect("/index.html"); return "Redirect"; }); - get("/all", Routes::getAll); - post("/new", Routes::addUrl); + + path("/api", () -> { + before("/*", authFilter); + get("/all", Routes::getAll); + post("/new", Routes::addUrl); + delete("/:shortUrl", Routes::delete); + }); + get("/:shortUrl", Routes::goToLongUrl); } } diff --git a/src/main/java/tk/draganczuk/url/Filters.java b/src/main/java/tk/draganczuk/url/Filters.java new file mode 100644 index 0000000..83ad996 --- /dev/null +++ b/src/main/java/tk/draganczuk/url/Filters.java @@ -0,0 +1,20 @@ +package tk.draganczuk.url; + +import com.qmetric.spark.authentication.AuthenticationDetails; +import com.qmetric.spark.authentication.BasicAuthenticationFilter; +import spark.Filter; +import spark.Request; +import spark.Response; + +public class Filters { + public static void addGZIP(Request request, Response response) { + response.header("Content-Encoding", "gzip"); + } + + public static Filter createAuthFilter() { + String username = System.getenv("username"); + String password = System.getenv("password"); + + return new BasicAuthenticationFilter(new AuthenticationDetails(username, password)); + } +} diff --git a/src/main/java/tk/draganczuk/url/Routes.java b/src/main/java/tk/draganczuk/url/Routes.java index dad75b8..2e9b590 100644 --- a/src/main/java/tk/draganczuk/url/Routes.java +++ b/src/main/java/tk/draganczuk/url/Routes.java @@ -1,5 +1,6 @@ package tk.draganczuk.url; +import org.eclipse.jetty.http.HttpStatus; import spark.Request; import spark.Response; @@ -29,15 +30,21 @@ public class Routes { shortUrl = Utils.randomString(); } - return urlFile.addUrl(longUrl, shortUrl); + if (Utils.validate(shortUrl)) { + return urlFile.addUrl(longUrl, shortUrl); + } else { + res.status(HttpStatus.BAD_REQUEST_400); + return "shortUrl not valid ([a-z0-9]+)"; + } } - public static String goToLongUrl(Request req, Response res){ + + public static String goToLongUrl(Request req, Response res) { String shortUrl = req.params("shortUrl"); var longUrlOpt = urlFile - .findForShortUrl(shortUrl); + .findForShortUrl(shortUrl); - if(longUrlOpt.isEmpty()){ + if (longUrlOpt.isEmpty()) { res.status(404); return ""; } @@ -47,4 +54,17 @@ public class Routes { return ""; } + public static String delete(Request req, Response res) { + String shortUrl = req.params("shortUrl"); + var longUrlOpt = urlFile + .findForShortUrl(shortUrl); + + if (longUrlOpt.isEmpty()) { + res.status(404); + return ""; + } + + urlFile.deleteEntry(String.format("%s,%s", shortUrl, longUrlOpt.get())); + return ""; + } } diff --git a/src/main/java/tk/draganczuk/url/UrlFile.java b/src/main/java/tk/draganczuk/url/UrlFile.java index 858a5e2..f666fb2 100644 --- a/src/main/java/tk/draganczuk/url/UrlFile.java +++ b/src/main/java/tk/draganczuk/url/UrlFile.java @@ -3,6 +3,7 @@ package tk.draganczuk.url; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.List; import java.util.Optional; @@ -41,18 +42,41 @@ public class UrlFile { public Optional findForShortUrl(String shortUrl){ try { - return Files.lines(file.toPath()) - .map(this::splitLine) - .filter(pair -> pair.getLeft().equals(shortUrl)) - .map(Pair::getRight) - .findAny(); + return Files.lines(file.toPath()) + .map(this::splitLine) + .filter(pair -> pair.getLeft().equals(shortUrl)) + .map(Pair::getRight) + .findAny(); } catch (IOException e) { return Optional.empty(); } } - public Pair splitLine(String line){ + public Pair splitLine(String line) { var split = line.split(","); return new Pair<>(split[0], split[1]); } + + public void deleteEntry(String entry) { + try { + File tmp = File.createTempFile(file.getName(), ".tmp"); + if (!tmp.exists()) { + tmp.createNewFile(); + } + + Files.lines(file.toPath()) + .filter(line -> !line.equals(entry)) + .forEach(line -> { + try { + Files.writeString(tmp.toPath(), line + "\n", StandardOpenOption.APPEND); + } catch (IOException e) { + e.printStackTrace(); + } + }); + + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + e.printStackTrace(); + } + } } diff --git a/src/main/java/tk/draganczuk/url/Utils.java b/src/main/java/tk/draganczuk/url/Utils.java index 0c868c4..174a7b5 100644 --- a/src/main/java/tk/draganczuk/url/Utils.java +++ b/src/main/java/tk/draganczuk/url/Utils.java @@ -1,23 +1,30 @@ package tk.draganczuk.url; import java.util.Random; +import java.util.regex.Pattern; public class Utils { private static final Random random = new Random(System.currentTimeMillis()); + private static final String SHORT_URL_PATTERN = "[a-z0-9_-]+"; + private static final Pattern PATTERN = Pattern.compile(SHORT_URL_PATTERN); + public static String randomString() { int leftLimit = 48; // numeral '0' int rightLimit = 122; // letter 'z' int targetStringLength = 10; - String generatedString = random.ints(leftLimit, rightLimit + 1) - .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) + return random.ints(leftLimit, rightLimit + 1) + .filter(i -> (i <= 57 || i >= 97)) .limit(targetStringLength) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) .toString(); + } - return generatedString; + public static boolean validate(String shortUrl) { + return PATTERN.matcher(shortUrl) + .matches(); } } diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html index 988384d..15e29e4 100644 --- a/src/main/resources/public/index.html +++ b/src/main/resources/public/index.html @@ -30,11 +30,13 @@ Add new URL
- +
- - + +
@@ -48,6 +50,7 @@ Long URL Short url + diff --git a/src/main/resources/public/js/main.js b/src/main/resources/public/js/main.js index 0a9acbf..a3c335a 100644 --- a/src/main/resources/public/js/main.js +++ b/src/main/resources/public/js/main.js @@ -1,5 +1,5 @@ const refreshData = async () => { - let data = await fetch("/all").then(res => res.text()); + let data = await fetch("/api/all").then(res => res.text()); data = data .split("\n") .filter(line => line !== "") @@ -23,9 +23,11 @@ const TR = (row) => { const tr = document.createElement("tr"); const longTD = TD(A(row.long)); const shortTD = TD(A_INT(row.short)); + const btn = deleteButton(row.short); tr.appendChild(longTD); tr.appendChild(shortTD); + tr.appendChild(btn); return tr; }; @@ -33,6 +35,21 @@ const TR = (row) => { const A = (s) => `${s}`; const A_INT = (s) => `${window.location.host}/${s}`; +const deleteButton = (shortUrl) => { + const btn = document.createElement("button"); + + btn.innerHTML = "×"; + + btn.onclick = e => { + e.preventDefault(); + fetch(`/api/${shortUrl}`, { + method: "DELETE" + }).then(_ => refreshData()); + }; + + return btn; +}; + const TD = (s) => { const td = document.createElement("td"); td.innerHTML = s; @@ -44,7 +61,7 @@ const submitForm = () => { const longUrl = form.elements["longUrl"]; const shortUrl = form.elements["shortUrl"]; - const url = `/new?long=${longUrl.value}&short=${shortUrl.value}`; + const url = `/api/new?long=${longUrl.value}&short=${shortUrl.value}`; fetch(url, { method: "POST"