From 778e2fedae9624fe142bff9eeceaa1bca2c1aa71 Mon Sep 17 00:00:00 2001 From: Elias Haugsbakk Date: Sat, 18 Jul 2026 00:16:59 +0200 Subject: implement API for uploading editing and deleting posts and images --- .../java/no/eliashaugsbakk/clams/server/Main.java | 81 +++++++---- .../clams/server/config/AppConfig.java | 2 +- .../clams/server/config/AppContext.java | 47 ++++++ .../clams/server/config/AppRoutes.java | 38 +++++ .../clams/server/controller/BlogController.java | 1 - .../clams/server/controller/MediaController.java | 157 +++++++++++++++++++++ .../clams/server/controller/PostController.java | 31 ++++ .../clams/server/model/ImageMetaData.java | 11 ++ .../no/eliashaugsbakk/clams/server/model/Post.java | 8 +- .../eliashaugsbakk/clams/server/model/PostDTO.java | 4 + .../clams/server/repository/BlogPostRepo.java | 5 + .../server/repository/BlogPostRepoSqlite.java | 92 ++++++++++++ .../clams/server/repository/MediaRepo.java | 13 ++ .../clams/server/repository/MediaRepoSqlite.java | 120 ++++++++++++++++ .../clams/server/repository/SqliteManager.java | 10 ++ .../clams/server/service/SlugService.java | 42 ++++++ 16 files changed, 629 insertions(+), 33 deletions(-) create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppContext.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppRoutes.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/MediaController.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/PostController.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/ImageMetaData.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostDTO.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepo.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepoSqlite.java create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/SlugService.java (limited to 'clams-server/src/main/java/no/eliashaugsbakk/clams') diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java index 18dd2cd..8f494ed 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java @@ -1,40 +1,61 @@ package no.eliashaugsbakk.clams.server; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import io.javalin.Javalin; import io.javalin.rendering.template.JavalinPebble; -import java.nio.file.Path; import java.util.Map; -import no.eliashaugsbakk.clams.server.config.AppConfig; -import no.eliashaugsbakk.clams.server.controller.BlogController; -import no.eliashaugsbakk.clams.server.repository.SqliteManager; +import no.eliashaugsbakk.clams.server.config.AppContext; +import no.eliashaugsbakk.clams.server.config.AppRoutes; public class Main { void main() { - AppConfig appConfig = new AppConfig(); - appConfig.loadConfig(); - String dbUrl = Path.of(appConfig.getStorageLocation()).resolve("posts.db").toString(); - SqliteManager dbManager = new SqliteManager(dbUrl); - dbManager.init(); - - BlogController blogController = new BlogController(dbManager); - - var app = Javalin.create(config -> { - config.staticFiles.add("/public"); - config.fileRenderer(new JavalinPebble()); - - config.routes.error(404, ctx -> ctx.render("templates/404.html", - Map.of("page_title", "404 - Page Not Found", "page_css", "404"))); - - config.routes.get("/", ctx -> ctx.redirect("/home")); - config.routes.get("/home", ctx -> ctx.render("templates/home.html", - Map.of("page_title", "Elias Haugsbakk", "page_css", "home"))); - config.routes.get("/projects", ctx -> ctx.render("templates/projects.html", - Map.of("page_title", "Projects - Elias Haugsbakk", "page_css", "projects"))); - - config.routes.get("/blog", blogController::handleBlogRequest); - config.routes.get("/blog/{slug}", blogController::handleGetPost); - - config.events.serverStopped(dbManager::close); - }).start(7070); + // Initialize dependencies + AppContext context = new AppContext(); + + try { + Javalin.create(config -> { + config.staticFiles.add("/public"); + config.fileRenderer(new JavalinPebble()); + + config.routes.error(404, ctx -> { + if (ctx.path().startsWith("/api")) { + ctx.json(Map.of( + "error", "Not Found", + "message", "The requested API endpoint does not exist." + )); + } else { + ctx.render("templates/404.html", Map.of( + "page_title", "404 - Page Not Found", + "page_css", "404" + )); + } + }); + + config.routes.exception(UnrecognizedPropertyException.class, (e, ctx) -> ctx.status(400).json(Map.of( + "error", "Bad Request", + "message", "Unrecognized property: '" + e.getPropertyName() + "'" + ))); + + config.routes.exception(Exception.class, (e, ctx) -> { + e.printStackTrace(); + ctx.status(500); + + if (ctx.path().startsWith("/api")) { + ctx.json(Map.of("error", "Internal Server Error")); + } else { + ctx.result("Internal Server Error. Please try again later."); + } + }); + + config.routes.apiBuilder(new AppRoutes(context)); + + config.events.serverStopped(context::close); + }).start(7070); + + + } catch (Exception e) { + context.close(); + throw e; + } } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppConfig.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppConfig.java index 8f460f5..1d68166 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppConfig.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppConfig.java @@ -54,6 +54,6 @@ public class AppConfig { } public String getStorageLocation() { - return properties.getProperty("storage_location", "./data/"); + return properties.getProperty("storage_location", "./data"); } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppContext.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppContext.java new file mode 100644 index 0000000..c5ef242 --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppContext.java @@ -0,0 +1,47 @@ +package no.eliashaugsbakk.clams.server.config; + +import java.nio.file.Path; +import no.eliashaugsbakk.clams.server.controller.BlogController; +import no.eliashaugsbakk.clams.server.controller.MediaController; +import no.eliashaugsbakk.clams.server.controller.PostController; +import no.eliashaugsbakk.clams.server.repository.BlogPostRepo; +import no.eliashaugsbakk.clams.server.repository.BlogPostRepoSqlite; +import no.eliashaugsbakk.clams.server.repository.MediaRepo; +import no.eliashaugsbakk.clams.server.repository.MediaRepoSqlite; +import no.eliashaugsbakk.clams.server.repository.SqliteManager; +import no.eliashaugsbakk.clams.server.service.SlugService; + +public class AppContext implements AutoCloseable { + private final SqliteManager dbManager; + private final BlogController blogController; + private final MediaController mediaController; + private final PostController postController; + + public AppContext() { + AppConfig appConfig = new AppConfig(); + appConfig.loadConfig(); + String dbUrl = Path.of(appConfig.getStorageLocation()).resolve("clams.db").toString(); + + this.dbManager = new SqliteManager(dbUrl); + this.dbManager.init(); + + BlogPostRepo blogPostRepo = new BlogPostRepoSqlite(dbManager); + MediaRepo mediaRepo = new MediaRepoSqlite(dbManager); + SlugService slugService = new SlugService(blogPostRepo); + + this.blogController = new BlogController(dbManager); + this.mediaController = new MediaController(mediaRepo, appConfig); + this.postController = new PostController(blogPostRepo, slugService); + } + + public BlogController getBlogController() { return blogController; } + public MediaController getMediaController() { return mediaController; } + public PostController getPostController() { return postController; } + + @Override + public void close() { + if (dbManager != null) { + dbManager.close(); + } + } +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppRoutes.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppRoutes.java new file mode 100644 index 0000000..fb42dfc --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/config/AppRoutes.java @@ -0,0 +1,38 @@ +package no.eliashaugsbakk.clams.server.config; + +import static io.javalin.apibuilder.ApiBuilder.*; +import io.javalin.apibuilder.EndpointGroup; +import java.util.Map; + +public class AppRoutes implements EndpointGroup { + private final AppContext ctx; + + public AppRoutes(AppContext ctx) { + this.ctx = ctx; + } + + @Override + public void addEndpoints() { + get("/", ctx -> ctx.redirect("/home")); + get("/home", ctx -> ctx.render("templates/home.html", + Map.of("page_title", "Elias Haugsbakk", "page_css", "home"))); + get("/projects", ctx -> ctx.render("templates/projects.html", + Map.of("page_title", "Projects - Elias Haugsbakk", "page_css", "projects"))); + + path("blog", () -> { + get(ctx.getBlogController()::handleBlogRequest); + get("{slug}", ctx.getBlogController()::handleGetPost); + }); + + path("api", () -> { + post("posts", ctx.getPostController()::handlePostPost); + put("posts/{slug}", ctx.getPostController()::handlePutPost); + delete("posts/{slug}", ctx.getPostController()::handleDeletePost); + + get("media", ctx.getMediaController()::handleGetMediaIndex); + get("media/{uuid}", ctx.getMediaController()::handleGetMedia); + post("media", ctx.getMediaController()::handlePostMedia); + delete("media/{uuid}", ctx.getMediaController()::handleDeleteMedia); + }); + } +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java index 58ce6d4..33a6ac6 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java @@ -74,7 +74,6 @@ public class BlogController { .toList(); Map> postsByYear = groupPostsByYear(allPosts); - // Manually list featured posts var featuredSlugs = List.of( "" ); diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/MediaController.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/MediaController.java new file mode 100644 index 0000000..8855b7b --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/MediaController.java @@ -0,0 +1,157 @@ +package no.eliashaugsbakk.clams.server.controller; + +import io.javalin.http.Context; +import io.javalin.http.UploadedFile; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import no.eliashaugsbakk.clams.server.config.AppConfig; +import no.eliashaugsbakk.clams.server.model.ImageMetaData; +import no.eliashaugsbakk.clams.server.repository.MediaRepo; +import org.apache.commons.imaging.ImageFormat; +import org.apache.commons.imaging.ImageFormats; +import org.apache.commons.imaging.ImageInfo; +import org.apache.commons.imaging.Imaging; + +public class MediaController { + private final MediaRepo mediaRepo; + private final AppConfig appConfig; + + public MediaController(MediaRepo mediaRepo, AppConfig appConfig) { + this.mediaRepo = mediaRepo; + this.appConfig = appConfig; + } + + public void handlePostMedia(Context ctx) { + UploadedFile file = ctx.uploadedFile("image"); + + if (file == null) { + ctx.status(400).result("Missing image file payload."); + return; + } + + try (InputStream is = file.content()) { + byte[] imageBytes = is.readAllBytes(); + + try (ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes)) { + ImageInfo info = Imaging.getImageInfo(bais, file.filename()); + ImageFormat format = info.getFormat(); + + if (format != ImageFormats.JPEG) { + ctx.status(415).result("Unsupported format: only jpeg allowed."); + return; + } + + if (info.getWidth() > 2000 || info.getHeight() > 2000) { + ctx.status(400).result("Image dimensions are too large: max allowed 2000x2000"); + return; + } + } + + UUID generatedUuid = saveToStorage(imageBytes, file.filename(), file.contentType()); + + ctx.status(201).json(Map.of("uuid", generatedUuid, "url", "/api/media/" + generatedUuid)); + } catch (Exception e) { + ctx.status(400).result("Corrupted or invalid image data."); + } + } + + private UUID saveToStorage(byte[] bytes, String originalFilename, String contentType) + throws IOException { + UUID uuid = UUID.randomUUID(); + Path path = Path.of(appConfig.getStorageLocation(), "images", uuid + ".jpeg"); + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + + try { + mediaRepo.addImage(new ImageMetaData(uuid, originalFilename, contentType, Instant.now())); + return uuid; + } catch (Exception e) { + Files.deleteIfExists(path); + throw new IOException("Failed to add image record to database", e); + } + } + + public void handleGetMedia(Context ctx) { + String uuidStr = ctx.pathParam("uuid"); + UUID uuid; + + try { + uuid = UUID.fromString(uuidStr); + } catch (IllegalArgumentException e) { + ctx.status(400).result("Invalid UUID format."); + return; + } + + if (mediaRepo.getImage(uuid).isEmpty()) { + ctx.status(404).result("Image not found."); + return; + } + + Path path = Path.of(appConfig.getStorageLocation(), "images", uuid + ".jpeg"); + + if (Files.exists(path)) { + ctx.contentType("image/jpeg"); + + try { + ctx.result(Files.newInputStream(path)); + } catch (IOException e) { + ctx.status(500).result("Error reading image file."); + } + } else { + ctx.status(404).result("Image file missing from storage."); + } + } + + public record ImageResponse(UUID uuid, String originalFilename, String contentType, + String timeUploaded) { + } + + + public void handleGetMediaIndex(Context ctx) { + List metaDataList = mediaRepo.getAllImagesMetaData(); + + List responseList = metaDataList.stream().map( + meta -> new ImageResponse(meta.uuid(), meta.originalFilename(), meta.contentType(), + meta.timeUploaded().toString())).toList(); + + ctx.status(200).json(responseList); + } + + public void handleDeleteMedia(Context ctx) { + UUID uuid; + try { + uuid = UUID.fromString(ctx.pathParam("uuid")); + } catch (IllegalArgumentException e) { + ctx.status(400).result("Invalid UUID format."); + return; + } + + try { + boolean deleted = mediaRepo.deleteImage(uuid); + if (!deleted) { + ctx.status(404).result("Image not found."); + return; + } + } catch (Exception e) { + ctx.status(500).result("Failed to delete image metadata from database."); + return; + } + + Path path = Path.of(appConfig.getStorageLocation(), "images", uuid + ".jpeg"); + try { + Files.deleteIfExists(path); + } catch (IOException e) { + ctx.status(500).result("Failed to delete image file from storage."); + return; + } + + ctx.status(204); + } +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/PostController.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/PostController.java new file mode 100644 index 0000000..93976fd --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/PostController.java @@ -0,0 +1,31 @@ +package no.eliashaugsbakk.clams.server.controller; + +import io.javalin.http.Context; +import no.eliashaugsbakk.clams.server.model.Post; +import no.eliashaugsbakk.clams.server.model.PostDTO; +import no.eliashaugsbakk.clams.server.repository.BlogPostRepo; +import no.eliashaugsbakk.clams.server.service.SlugService; + +public class PostController { + private final BlogPostRepo blogPostRepo; + private final SlugService slugService; + + public PostController(BlogPostRepo blogPostRepo, SlugService slugService) { + this.blogPostRepo = blogPostRepo; + this.slugService = slugService; + } + + public void handlePostPost(Context ctx) { + PostDTO newPost = ctx.bodyAsClass(PostDTO.class); + blogPostRepo.addBlogPost(new Post(newPost, slugService.toSlug(newPost.title()))); + } + + public void handlePutPost(Context ctx) { + PostDTO updatedPost = ctx.bodyAsClass(PostDTO.class); + blogPostRepo.updateBlogPost(new Post(updatedPost, ctx.pathParam("slug"))); + } + + public void handleDeletePost(Context ctx) { + blogPostRepo.deleteBlogPost(ctx.pathParam("slug")); + } +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/ImageMetaData.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/ImageMetaData.java new file mode 100644 index 0000000..e156d9a --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/ImageMetaData.java @@ -0,0 +1,11 @@ +package no.eliashaugsbakk.clams.server.model; + +import java.time.Instant; +import java.util.UUID; + +public record ImageMetaData( + UUID uuid, + String originalFilename, + String contentType, + Instant timeUploaded +) {} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/Post.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/Post.java index f5e963f..4a7fcf6 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/Post.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/Post.java @@ -2,5 +2,11 @@ package no.eliashaugsbakk.clams.server.model; import java.time.Instant; -public record Post(String title, String slug, String summary, Instant timePublished, String content, boolean isPublished) { +public record Post(String title, String slug, String summary, Instant timePublished, String content, + boolean isPublished) { + public Post(PostDTO postDTO, String slug) { + + this(postDTO.title(), slug, postDTO.summary(), Instant.now(), postDTO.content(), + postDTO.isPublished()); + } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostDTO.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostDTO.java new file mode 100644 index 0000000..12254da --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostDTO.java @@ -0,0 +1,4 @@ +package no.eliashaugsbakk.clams.server.model; + +public record PostDTO(String title, String summary, String content, boolean isPublished) { +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java index 8e43e2c..6455f7d 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java @@ -9,4 +9,9 @@ public interface BlogPostRepo { List listPostsMetaData(); Optional getPost(String slug); List searchPostsBody(String query); + + void addBlogPost(Post post); + void updateBlogPost(Post post); + void deleteBlogPost(String slug); + boolean existsPostBySlug(String slug); } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java index 9da6818..754c915 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java @@ -119,4 +119,96 @@ public class BlogPostRepoSqlite implements BlogPostRepo { throw new RepoException("Error searching posts for query: " + query, e); } } + + @Override + public void addBlogPost(Post post) { + String sql = """ + INSERT INTO posts (slug, title, content, summary, published, is_published) + VALUES (?, ?, ?, ?, ?, ?) + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, post.slug()); + stmt.setString(2, post.title()); + stmt.setString(3, post.content()); + stmt.setString(4, post.summary()); + stmt.setString(5, post.timePublished().toString()); + stmt.setBoolean(6, post.isPublished()); + + stmt.executeUpdate(); + + } catch (SQLException e) { + throw new RepoException("Error adding blog post: " + post.slug(), e); + } + } + + @Override + public void updateBlogPost(Post post) { + String sql = """ + UPDATE posts + SET title = ?, content = ?, summary = ?, published = ?, last_edited = ?, is_published = ? + WHERE slug = ? + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, post.title()); + stmt.setString(2, post.content()); + stmt.setString(3, post.summary()); + stmt.setString(4, post.timePublished().toString()); + stmt.setString(5, Instant.now().toString()); + stmt.setBoolean(6, post.isPublished()); + stmt.setString(7, post.slug()); + + stmt.executeUpdate(); + + } catch (SQLException e) { + throw new RepoException("Error updating blog post: " + post.slug(), e); + } + } + + @Override + public void deleteBlogPost(String slug) { + String sql = """ + DELETE FROM posts + WHERE slug = ? + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, slug); + + stmt.executeUpdate(); + + } catch (SQLException e) { + throw new RepoException("Error deleting blog post: " + slug, e); + } + } + + @Override + public boolean existsPostBySlug(String slug) { + String sql = """ + SELECT 1 + FROM posts + WHERE slug = ? + LIMIT 1 + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, slug); + + try (ResultSet resultSet = stmt.executeQuery()) { + return resultSet.next(); + } + + } catch (SQLException e) { + throw new RepoException("Error checking existence of post by slug: " + slug, e); + } + } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepo.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepo.java new file mode 100644 index 0000000..163430c --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepo.java @@ -0,0 +1,13 @@ +package no.eliashaugsbakk.clams.server.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import no.eliashaugsbakk.clams.server.model.ImageMetaData; + +public interface MediaRepo { + void addImage(ImageMetaData image); + boolean deleteImage(UUID uuid); + Optional getImage(UUID uuid); + List getAllImagesMetaData(); +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepoSqlite.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepoSqlite.java new file mode 100644 index 0000000..143841c --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/MediaRepoSqlite.java @@ -0,0 +1,120 @@ +package no.eliashaugsbakk.clams.server.repository; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import no.eliashaugsbakk.clams.server.model.ImageMetaData; + +public class MediaRepoSqlite implements MediaRepo { + private final SqliteManager manager; + + public MediaRepoSqlite(SqliteManager sqliteManager) { + this.manager = sqliteManager; + } + + @Override + public void addImage(ImageMetaData image) { + String sql = """ + INSERT INTO images (uuid, original_filename, extension, time_uploaded) + VALUES (?, ?, ?, ?) + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, image.uuid().toString()); + stmt.setString(2, image.originalFilename()); + stmt.setString(3, image.contentType()); + stmt.setString(4, image.timeUploaded().toString()); + + stmt.executeUpdate(); + + } catch (SQLException e) { + throw new RepoException("Error adding image: " + image.uuid(), e); + } + } + + @Override + public boolean deleteImage(UUID uuid) { + String sql = """ + DELETE FROM images + WHERE uuid = ? + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, uuid.toString()); + + int rowsAffected = stmt.executeUpdate(); + return rowsAffected > 0; + + } catch (SQLException e) { + throw new RepoException("Error deleting image: " + uuid, e); + } + } + + @Override + public Optional getImage(UUID uuid) { + String sql = """ + SELECT uuid, original_filename, extension, time_uploaded + FROM images + WHERE uuid = ? + """; + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, uuid.toString()); + + try (ResultSet resultSet = stmt.executeQuery()) { + if (resultSet.next()) { + String originalFilename = resultSet.getString("original_filename"); + String extension = resultSet.getString("extension"); + Instant timeUploaded = Instant.parse(resultSet.getString("time_uploaded")); + + return Optional.of(new ImageMetaData(uuid, originalFilename, extension, timeUploaded)); + } + return Optional.empty(); + } + + } catch (SQLException e) { + throw new RepoException("Error fetching image: " + uuid, e); + } + } + + @Override + public List getAllImagesMetaData() { + String sql = """ + SELECT uuid, original_filename, extension, time_uploaded + FROM images + """; + + List images = new ArrayList<>(); + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet resultSet = stmt.executeQuery()) { + + while (resultSet.next()) { + UUID uuid = UUID.fromString(resultSet.getString("uuid")); + String originalFilename = resultSet.getString("original_filename"); + String extension = resultSet.getString("extension"); + Instant timeUploaded = Instant.parse(resultSet.getString("time_uploaded")); + + images.add(new ImageMetaData(uuid, originalFilename, extension, timeUploaded)); + } + + return images; + + } catch (SQLException e) { + throw new RepoException("Error fetching all images metadata", e); + } + } +} diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/SqliteManager.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/SqliteManager.java index 6fc5daf..a192cee 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/SqliteManager.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/SqliteManager.java @@ -59,12 +59,22 @@ public class SqliteManager implements AutoCloseable { ); """; + String images = """ + CREATE TABLE IF NOT EXISTS images ( + uuid TEXT PRIMARY KEY, + original_filename TEXT NOT NULL, + extension TEXT, + time_uploaded TEXT NOT NULL + ); + """; + try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(posts); stmt.execute(idxPublished); stmt.execute(tags); stmt.execute(post_tags); + stmt.execute(images); } catch (SQLException e) { throw new RepoException("Error while initializing database", e); diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/SlugService.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/SlugService.java new file mode 100644 index 0000000..19af1da --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/SlugService.java @@ -0,0 +1,42 @@ +package no.eliashaugsbakk.clams.server.service; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; +import no.eliashaugsbakk.clams.server.repository.BlogPostRepo; + +public class SlugService { + private final BlogPostRepo blogPostRepo; + private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]"); + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + + public SlugService(BlogPostRepo blogPostRepo) { + this.blogPostRepo = blogPostRepo; + } + + public String toSlug(String input) { + if (input == null) { + return ""; + } + + String slug = input.toLowerCase(Locale.ENGLISH); + slug = slug.replace("æ", "ae") + .replace("ø", "oe") + .replace("å", "aa"); + + slug = WHITESPACE.matcher(slug).replaceAll("-"); + slug = Normalizer.normalize(slug, Normalizer.Form.NFD); + slug = NON_LATIN.matcher(slug).replaceAll(""); + + String finalSlug = slug; + int counter = 2; + while (true) { + if (blogPostRepo.existsPostBySlug(finalSlug)) { + finalSlug = slug + "-" + counter; + counter++; + } else { + return finalSlug; + } + } + } +} -- cgit v1.2.3