make it work and make it better

added html, redirects, did you mean, maybe other stuff I forgot
This commit is contained in:
Minecon724 2024-09-10 12:43:37 +02:00
parent b8d1a0512c
commit f0049a4d93
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
18 changed files with 318 additions and 119 deletions

View file

@ -30,6 +30,13 @@
</dependencyManagement> </dependencyManagement>
<dependencies> <dependencies>
<dependency>
<!-- jsoup HTML parser library @ https://jsoup.org/ -->
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId> <artifactId>quarkus-rest</artifactId>

View file

@ -12,6 +12,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.RestResponse.Status; import org.jboss.resteasy.reactive.RestResponse.Status;

View file

@ -0,0 +1,42 @@
package eu.m724.talkpages;
import eu.m724.talkpages.orm.entity.Account;
import eu.m724.talkpages.orm.entity.Page;
import eu.m724.talkpages.orm.entity.PageRevision;
import io.quarkus.runtime.LaunchMode;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
@Singleton
public class Startup {
@Inject
LaunchMode launchMode;
@Transactional
public void examplePage(@Observes StartupEvent ignoredEvent) {
Account account = new Account("System");
account.persistAndFlush();
addPage(account, "TalkPages", "<p>A website where the users collaboratively create content</p><ul><li><a href=\"https://git.m724.eu/Minecon724/talkpages\">Source code</a></li></ul>");
addPage(account, "Talkpages", "ambiguous for [TalkPages]");
addPage(account, "TP", "@TalkPages");
}
@Transactional
public void addPage(Account account, String title, String content) {
Page page = new Page(title);
PageRevision revision = new PageRevision(page);
revision.author = account;
revision.content = content;
revision.delta = revision.content.length();
page.setLatestRevision(revision);
account.revisions.add(revision);
revision.persistAndFlush();
}
}

View file

@ -0,0 +1,24 @@
package eu.m724.talkpages;
import io.quarkus.qute.TemplateExtension;
import jakarta.enterprise.context.ApplicationScoped;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
@ApplicationScoped
public class TemplateExtensions {
// disallowing images because abuse risk and a text only site is refreshing
private static final Safelist safelist = Safelist.relaxed().removeTags("img");
/**
* Sanitize HTML. This means remove &lt;script&gt; and stuff
*
* @param content the HTML content
* @return sanitized HTML content
*/
@TemplateExtension
public static String sanitizeContent(String content) {
// I was thinking maybe we could put somewhere that it was sanitized
return Jsoup.clean(content, safelist);
}
}

View file

@ -3,6 +3,7 @@ package eu.m724.talkpages.orm.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity; import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.transaction.Transactional;
import org.hibernate.annotations.GenericGenerator; import org.hibernate.annotations.GenericGenerator;
import java.util.ArrayList; import java.util.ArrayList;
@ -10,6 +11,12 @@ import java.util.List;
@Entity @Entity
public class Page extends PanacheEntityBase { public class Page extends PanacheEntityBase {
public Page() {}
public Page(String title) {
this.title = title;
}
@Id @Id
@GeneratedValue(generator = "title-path") @GeneratedValue(generator = "title-path")
@GenericGenerator(name = "title-path", strategy = "eu.m724.talkpages.orm.generator.PathGenerator") @GenericGenerator(name = "title-path", strategy = "eu.m724.talkpages.orm.generator.PathGenerator")
@ -33,4 +40,13 @@ public class Page extends PanacheEntityBase {
public static Page findByTitle(String title) { public static Page findByTitle(String title) {
return find("title", title).firstResult(); return find("title", title).firstResult();
} }
public static List<Page> findByTitleIgnoreCase(String title) {
return find("lower(title) = ?1", title.toLowerCase()).list();
}
public void setLatestRevision(PageRevision pageRevision) {
revisions.add(pageRevision);
latestRevision = pageRevision;
}
} }

View file

@ -11,7 +11,19 @@ import java.time.LocalDateTime;
uniqueConstraints = @UniqueConstraint(columnNames = {"id", "index"}) uniqueConstraints = @UniqueConstraint(columnNames = {"id", "index"})
) )
public class PageRevision extends PanacheEntity { public class PageRevision extends PanacheEntity {
@ManyToOne public PageRevision() {}
public PageRevision(Page page) {
long index = 1;
if (page.latestRevision != null) {
index = page.latestRevision.index + 1;
}
this.index = index;
this.page = page;
}
@ManyToOne(cascade = CascadeType.ALL)
public Page page; public Page page;
/** /**
@ -29,7 +41,7 @@ public class PageRevision extends PanacheEntity {
/** /**
* The author of the revision * The author of the revision
*/ */
@ManyToOne @ManyToOne(cascade = CascadeType.ALL)
public Account author; public Account author;
/** /**

View file

@ -1,95 +0,0 @@
package eu.m724.talkpages.page;
import eu.m724.talkpages.RedirectService;
import eu.m724.talkpages.orm.entity.Account;
import eu.m724.talkpages.orm.entity.Page;
import eu.m724.talkpages.orm.entity.PageRevision;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestResponse;
import java.net.URI;
import java.util.Random;
@Path("/action")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_PLAIN)
public class ActionResource {
@Inject
RedirectService redirectService;
@POST
@Path("/create")
@Transactional // TODO make this in a service or whatever is it called
public Response create(MultivaluedMap<String, String> formData) {
String title = formData.getFirst("title");
String content = formData.getFirst("content");
if (title.contains(":")) {
return Response.temporaryRedirect(URI.create("/edit/" + title)).status(Response.Status.SEE_OTHER).build();
}
if (Page.findByTitle(title) != null) {
return Response.status(Response.Status.CONFLICT).build();
}
Page page = new Page();
page.title = title;
Account account = new Account("test user #" + new Random().nextInt(0, 1000));
PageRevision revision = new PageRevision();
revision.author = account;
revision.content = content;
revision.delta = content.length();
revision.page = page;
page.revisions.add(revision);
page.latestRevision = revision;
account.persist();
page.persist();
revision.persistAndFlush(); // this stalled me for a few hours... should move that to @Transactional
return redirectService.page(page).status(RestResponse.Status.SEE_OTHER).build();
}
@POST
@Path("/edit")
@Transactional // TODO make this in a service or whatever is it called
public Response edit(MultivaluedMap<String, String> formData) {
String title = formData.getFirst("title");
String content = formData.getFirst("content");
Page page = Page.findByTitle(title);
if (Page.findByTitle(title) == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
Account account = new Account("test user #" + new Random().nextInt(0, 1000));
PageRevision revision = new PageRevision();
revision.index = page.latestRevision.index + 1;
revision.author = account;
revision.content = content;
revision.delta = content.length() - page.latestRevision.content.length(); // TODO optimize
revision.page = page;
page.revisions.add(revision);
page.latestRevision = revision;
account.persist();
page.persist();
revision.persistAndFlush(); // this stalled me for a few hours
return redirectService.page(page).status(RestResponse.Status.SEE_OTHER).build();
}
}

View file

@ -29,15 +29,19 @@ public class EditResource {
} }
@GET @GET
@Path("/{title}") @Path("/{title:.+}")
public Response editPage(@PathParam("title") String title) { public Response editPage(@PathParam("title") String title, @CookieParam("prefilledContent") String prefilledContent) {
Page page = Page.findByTitle(title); Page page = Page.findByTitle(title);
if (page == null) if (page == null)
return Response.status(Response.Status.NOT_FOUND).entity(Templates.create(title, "198.51.100.42")).build(); return Response.ok().entity(Templates.create(title, "198.51.100.42")).build();
if (prefilledContent == null || prefilledContent.isBlank()) {
prefilledContent = page.latestRevision.content;
}
// TODO check for permissions // TODO check for permissions
return Response.ok().entity(Templates.edit(page, page.latestRevision.content, "198.51.100.42")).build(); return Response.ok().entity(Templates.edit(page, prefilledContent, "198.51.100.42")).build();
} }
} }

View file

@ -41,7 +41,7 @@ public class HistoryResource {
if (page == null) if (page == null)
return Response.status(Response.Status.NOT_FOUND).entity(Templates.notFound(title, redirectService.titleEncoded(title))).build(); // TODO replace with own template return Response.status(Response.Status.NOT_FOUND).entity(Templates.notFound(title, redirectService.titleEncoded(title))).build(); // TODO replace with own template
List<PageRevision> revisions = page.revisions; List<PageRevision> revisions = page.revisions.reversed();
return Response.ok().entity(Templates.history(page, revisions)).build(); return Response.ok().entity(Templates.history(page, revisions)).build();
} }

View file

@ -1,5 +1,6 @@
package eu.m724.talkpages.page; package eu.m724.talkpages.page;
import eu.m724.talkpages.TemplateExtensions;
import eu.m724.talkpages.RedirectService; import eu.m724.talkpages.RedirectService;
import eu.m724.talkpages.orm.entity.Page; import eu.m724.talkpages.orm.entity.Page;
import eu.m724.talkpages.orm.entity.PageRevision; import eu.m724.talkpages.orm.entity.PageRevision;
@ -10,6 +11,11 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Path("/page") @Path("/page")
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public class PageResource { public class PageResource {
@ -20,7 +26,7 @@ public class PageResource {
public static class Templates { public static class Templates {
public static native TemplateInstance page(Page page, PageRevision revision, boolean old); public static native TemplateInstance page(Page page, PageRevision revision, boolean old);
public static native TemplateInstance revisionNotFound(Page page, int revisionId); public static native TemplateInstance revisionNotFound(Page page, int revisionId);
public static native TemplateInstance notFound(String title); public static native TemplateInstance notFound(String title, List<Page> suggestions);
} }
@GET @GET
@ -31,15 +37,25 @@ public class PageResource {
@GET @GET
@Path("/{pageId}") @Path("/{pageId}")
public Response viewPage(@PathParam("pageId") String pageId, @QueryParam("revision") Integer revisionId) { public Response viewPage(@PathParam("pageId") String pageId, @QueryParam("revision") Integer revisionId, @QueryParam("redirectFrom") String redirectFrom) {
System.out.println(pageId); System.out.println(pageId);
Page page = Page.findByTitle(pageId); Page page = Page.findByTitle(pageId);
if (page == null) if (page == null) {
return Response.status(Response.Status.NOT_FOUND).entity(Templates.notFound(pageId)).build(); List<Page> suggestions = Page.findByTitleIgnoreCase(pageId);
if (suggestions.size() == 1) {
return redirectService.page(suggestions.getFirst()).build();
}
return Response.status(Response.Status.NOT_FOUND).entity(Templates.notFound(pageId, suggestions)).build();
}
if (revisionId == null) { if (revisionId == null) {
if (page.latestRevision.content.startsWith("@")) {
String target = page.latestRevision.content.substring(1);
if (redirectFrom == null)
redirectFrom = pageId;
return Response.temporaryRedirect(URI.create("/page/" + redirectService.titleEncoded(target) + "?redirectFrom=" + URLEncoder.encode(redirectFrom, StandardCharsets.UTF_8))).build(); // TODO I could really reduce some of this
}
return Response.ok().entity(Templates.page(page, page.latestRevision, false)).build(); return Response.ok().entity(Templates.page(page, page.latestRevision, false)).build();
} else { } else {
System.out.printf("History for page: %s %d\n", page.title, page.latestRevision.index); System.out.printf("History for page: %s %d\n", page.title, page.latestRevision.index);

View file

@ -0,0 +1,91 @@
package eu.m724.talkpages.page.action;
import eu.m724.talkpages.RedirectService;
import eu.m724.talkpages.orm.entity.Account;
import eu.m724.talkpages.orm.entity.Page;
import eu.m724.talkpages.orm.entity.PageRevision;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestResponse;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Random;
@Path("/action")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_PLAIN)
public class ActionResource {
@Inject
ActionService actionService;
@Inject
RedirectService redirectService;
@POST
@Path("/create")
@Transactional // TODO make this in a service or whatever is it called
public Response create(MultivaluedMap<String, String> formData) {
String title = formData.getFirst("title");
String content = formData.getFirst("content");
Account account = new Account("test user #" + new Random().nextInt(10000));
account.persistAndFlush();
try {
Page page = actionService.createPage(title, content, account);
return redirectService.page(page)
.cookie(new NewCookie.Builder("prefilledContent").value("").maxAge(0).build())
.status(RestResponse.Status.SEE_OTHER).build();
} catch (IllegalArgumentException e) { // TODO I could reduce all this code
// illegal title
String encodedMessage = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
return Response
.temporaryRedirect(URI.create("/edit/" + title + "?errorType=1&error=" + encodedMessage))
.cookie(new NewCookie.Builder("prefilledContent").path("/edit/" + title).value(content).build()) // TODO find a better way
.status(Response.Status.SEE_OTHER).build();
} catch (IllegalStateException e) {
// page already exists
String encodedMessage = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
return Response
.temporaryRedirect(URI.create("/edit/" + title + "?errorType=2&error=" + encodedMessage))
.cookie(new NewCookie.Builder("prefilledContent").path("/edit/" + title).value(content).build())
.status(Response.Status.SEE_OTHER).build();
} catch (Exception e) {
e.printStackTrace();
return Response
.temporaryRedirect(URI.create("/edit/" + title + "?errorType=0&error=Server+error"))
.cookie(new NewCookie.Builder("prefilledContent").path("/edit/" + title).value(content).build())
.status(Response.Status.SEE_OTHER).build();
}
}
@POST
@Path("/edit")
@Transactional // TODO make this in a service or whatever is it called
public Response edit(MultivaluedMap<String, String> formData) {
String title = formData.getFirst("title");
String content = formData.getFirst("content");
Account account = new Account("test user #" + new Random().nextInt(0, 1000));
Page page = Page.findByTitle(title);
if (Page.findByTitle(title) == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
page = actionService.editPage(page, content, account);
return redirectService.page(page).status(RestResponse.Status.SEE_OTHER).build();
}
}

View file

@ -0,0 +1,52 @@
package eu.m724.talkpages.page.action;
import eu.m724.talkpages.orm.entity.Account;
import eu.m724.talkpages.orm.entity.Page;
import eu.m724.talkpages.orm.entity.PageRevision;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ActionService {
@Transactional
Page createPage(String title, String content, Account account) {
// title and content is sanitized so only prohibit if necessary
if (title.contains("/")) {
throw new IllegalArgumentException("Title cannot contain slashes (/). Those are used for sub-pages.");
} else if (Page.findByTitle(title) != null) {
throw new IllegalStateException("Page already exists, I made you edit it.");
}
Page page = new Page(title);
page.persist();
PageRevision revision = new PageRevision(page);
revision.author = account;
revision.content = content;
revision.delta = content.length();
page.setLatestRevision(revision);
account.revisions.add(revision);
revision.persistAndFlush();
return page;
}
@Transactional
Page editPage(Page page, String newContent, Account account) {
// constraints are not checked
PageRevision revision = new PageRevision(page);
revision.author = account;
revision.content = newContent;
revision.delta = newContent.length() - page.latestRevision.content.length(); // TODO optimize
page.setLatestRevision(revision);
account.revisions.add(revision);
revision.persistAndFlush();
return page;
}
}

View file

@ -11,3 +11,7 @@
<br> <br>
<input type="submit"> <input type="submit">
</form> </form>
{#if http:param("error") != null}
<p>Error: <strong>{http:param("error")}</strong></p>
{/if}

View file

@ -8,3 +8,7 @@
<br> <br>
<input type="submit"> <input type="submit">
</form> </form>
{#if http:param("error") != null}
<p>Error: <strong>{http:param("error")}</strong></p>
{/if}

View file

@ -1,7 +1,11 @@
<h1>History of {page.title}</h1> <h1>History of {page.title}</h1>
{#for revision in revisions} {#for revision in revisions}
{#if page.latestRevision == revision}
<span><strong><a href="/page/{page.title}?revision={revision.index}">#{revision.index}</a> ({revision.delta}) {revision.timestamp.toString()} by <a href="/user/{revision.author.id}">{revision.author.name}</a></strong></span>
{#else}
<span><a href="/page/{page.title}?revision={revision.index}">#{revision.index}</a> ({revision.delta}) {revision.timestamp.toString()} by <a href="/user/{revision.author.id}">{revision.author.name}</a></span> <span><a href="/page/{page.title}?revision={revision.index}">#{revision.index}</a> ({revision.delta}) {revision.timestamp.toString()} by <a href="/user/{revision.author.id}">{revision.author.name}</a></span>
{/if}
<br> <br>
{/for} {/for}

View file

@ -1,12 +1,10 @@
<h1>Search</h1>
<form action="/search"> <form action="/search">
<label for="query">Query</label> <input type="text" name="query" id="query" placeholder="Search query">
<input type="text" name="query" id="query"> <input type="submit" value="Search">
<br>
<input type="submit">
</form> </form>
<ul> <ul>
<li><a href="/edit">Create a page</a></li> <li><a href="/edit">Create a page</a></li>
</ul> </ul>
<small>Running <a href="/page/TalkPages">TalkPages</a> version {config:["quarkus.application.version"]}</small>

View file

@ -1,6 +1,16 @@
<h1>{title}</h1> <h1>{title}</h1>
There is no such article. <p>There is no such article.</p>
{#if !suggestions.isEmpty()}
<p>Are you looking for:</p>
<ul>
{#for suggestion in suggestions}
<li><a href="/page/{suggestion.path}">{suggestion.title}</a></li>
{/for}
</ul>
<p>Actions:</p>
{/if}
<ul> <ul>
<li><a href="/edit/{title}">Create</a></li> <li><a href="/edit/{title}">Create</a></li>

View file

@ -1,13 +1,22 @@
{#if http:param("redirectFrom") != null}
<p>Redirected from {http:param("redirectFrom")}</p>
{/if}
{#if old} {#if old}
{#if page.latestRevision != revision}
<h3> <h3>
You are viewing an outdated revision #{revision.index} of this page from {revision.timestamp.toString()}, authored by {revision.author.name}. You are viewing an outdated revision #{revision.index} of this page from {revision.timestamp.toString()}, authored by {revision.author.name}.
<a href="/page/{page.title}">Go back to current version</a> <br>
<a href="/page/{page.title}">See current version</a>
</h3> </h3>
{#else}
<h4>This is the current revision #{revision.index} authored by {revision.author.name}.</h4>
{/if}
{/if} {/if}
<h1>{page.title}</h1> <h1>{page.title}</h1>
{revision.content} {revision.content.sanitizeContent.raw}
<br><br> <br><br>