From c788b63dd339e2e3c77736a02182f3a96380931d Mon Sep 17 00:00:00 2001 From: Jonathan Gamble Date: Sun, 21 May 2023 21:04:26 -0500 Subject: [PATCH 1/6] ublog image uploads paste from clipboard, drag and drop, and toolbar button --- app/controllers/Main.scala | 24 ++++++++++++++++++++++++ app/views/ublog/form.scala | 2 +- conf/routes | 1 + ui/site/css/ublog/_form.scss | 5 ----- ui/site/src/ublogForm.ts | 19 +++++++++++-------- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/app/controllers/Main.scala b/app/controllers/Main.scala index 15ea4fbde27a1..6a83aee5ec58d 100644 --- a/app/controllers/Main.scala +++ b/app/controllers/Main.scala @@ -201,3 +201,27 @@ Allow: / }.toFuccess def devAsset(v: String, path: String, file: String) = assetsC.at(path, file) + + private val ImageRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress]( + key = "user.image.ip" + )( + ("fast", 10, 2.minutes), + ("slow", 60, 1.day) + ) + + def uploadImage = AuthBody(parse.multipartFormData) { ctx ?=> me => + if lila.common.HTTPRequest.isXhr(ctx.req) then + ctx.body.body.file("image") match + case Some(image) => + ImageRateLimitPerIp(ctx.ip, rateLimitedFu): + env.memo.picfitApi + .uploadFile( + s"userimage:${ornicar.scalalib.ThreadLocalRandom.nextString(12)}", + image, + userId = me.id + ) + .map(pic => JsonOk(Json.obj("imageUrl" -> env.memo.picfitApi.url.resize(pic.id, Left(720))))) + case None => + fuccess(JsonBadRequest(jsonError("Image content only"))) + else fuccess(Forbidden) + } diff --git a/app/views/ublog/form.scala b/app/views/ublog/form.scala index 93b1bcd3c5b20..04e4f8fce6cfb 100644 --- a/app/views/ublog/form.scala +++ b/app/views/ublog/form.scala @@ -141,7 +141,7 @@ object form: ) { field => frag( form3.textarea(field)(), - div(id := "markdown-editor") + div(id := "markdown-editor", attr("data-url") := routes.Main.uploadImage) ) }, post.toOption match { diff --git a/conf/routes b/conf/routes index db63d6bbcf744..58cbf2165279b 100644 --- a/conf/routes +++ b/conf/routes @@ -815,6 +815,7 @@ GET /verify-title controllers.Main.verifyTitle GET /InstantChess.com controllers.Main.instantChess GET /daily-puzzle-slack controllers.Main.dailyPuzzleSlackApp GET /temporarily-disabled controllers.Main.temporarilyDisabled +POST /upload/image/user controllers.Main.uploadImage # Dev GET /dev/cli controllers.Dev.cli diff --git a/ui/site/css/ublog/_form.scss b/ui/site/css/ublog/_form.scss index 0e5ae67e26a7b..07a12a24ebd4e 100644 --- a/ui/site/css/ublog/_form.scss +++ b/ui/site/css/ublog/_form.scss @@ -152,10 +152,5 @@ display: none; } } - &-popup-add-image { - .toastui-editor-tabs { - display: none; - } - } } } diff --git a/ui/site/src/ublogForm.ts b/ui/site/src/ublogForm.ts index a6fec63fc365a..658c0dc94bfbe 100644 --- a/ui/site/src/ublogForm.ts +++ b/ui/site/src/ublogForm.ts @@ -50,6 +50,7 @@ const setupImage = (form: HTMLFormElement) => { const setupMarkdownEditor = (el: HTMLTextAreaElement) => { const postProcess = (markdown: string) => markdown.replace(/
/g, '').replace(/\n\s*#\s/g, '\n## '); + const editor: Editor = new Editor({ el, usageStatistics: false, @@ -71,8 +72,16 @@ const setupMarkdownEditor = (el: HTMLTextAreaElement) => { change: throttle(500, () => $('#form3-markdown').val(postProcess(editor.getMarkdown()))), }, hooks: { - addImageBlobHook() { - alert('Sorry, file upload in the post body is not supported. Only image URLs will work.'); + addImageBlobHook: (blob, cb) => { + const formData = new FormData(); + formData.append('image', blob); + xhr + .json(el.getAttribute('data-url')!, { method: 'POST', body: formData }) + .then(data => cb(data.imageUrl, '')) + .catch(e => { + cb(''); + throw e; + }); }, }, }); @@ -83,12 +92,6 @@ const setupMarkdownEditor = (el: HTMLTextAreaElement) => { if (okButton) $(okButton).trigger('click'); return !okButton; }); - $(el) - .find('button.image') - .on('click', () => { - $(el).find('.toastui-editor-popup-add-image .tab-item:last-child').trigger('click'); - $('#toastuiImageUrlInput')[0]?.focus(); - }); $(el) .find('button.link') .on('click', () => $('#toastuiLinkUrlInput')[0]?.focus()); From 740d81fc894fe884b1bf772e29db86c12ecf4e6f Mon Sep 17 00:00:00 2001 From: Jonathan Gamble Date: Sun, 21 May 2023 22:38:33 -0500 Subject: [PATCH 2/6] lichess forks can play too --- modules/common/src/main/MarkdownRender.scala | 89 ++++++++++---------- modules/ublog/src/main/Env.scala | 2 +- modules/ublog/src/main/UblogMarkup.scala | 6 +- 3 files changed, 49 insertions(+), 48 deletions(-) diff --git a/modules/common/src/main/MarkdownRender.scala b/modules/common/src/main/MarkdownRender.scala index b8d04e79bbae1..c742cd3a2236b 100644 --- a/modules/common/src/main/MarkdownRender.scala +++ b/modules/common/src/main/MarkdownRender.scala @@ -42,7 +42,8 @@ final class MarkdownRender( blockQuote: Boolean = false, list: Boolean = false, code: Boolean = false, - gameExpand: Option[MarkdownRender.GameExpand] = None + gameExpand: Option[MarkdownRender.GameExpand] = None, + assetDomain: String = "lichess1.org" ): private val extensions = java.util.ArrayList[Extension]() @@ -52,7 +53,7 @@ final class MarkdownRender( if (strikeThrough) extensions.add(StrikethroughExtension.create()) if (autoLink) extensions.add(AutolinkExtension.create()) - extensions.add(MarkdownRender.WhitelistedImage.extension) + extensions.add(MarkdownRender.WhitelistedImage.create(assetDomain)) extensions.add( gameExpand.fold[Extension](MarkdownRender.LilaLinkExtension) { MarkdownRender.GameEmbedExtension(_) } ) @@ -112,18 +113,6 @@ object MarkdownRender: private object WhitelistedImage: - val extension = new HtmlRenderer.HtmlRendererExtension: - override def rendererOptions(options: MutableDataHolder) = () - override def extend(htmlRendererBuilder: HtmlRenderer.Builder, rendererType: String) = - htmlRendererBuilder - .nodeRendererFactory(new NodeRendererFactory { - override def apply(options: DataHolder) = renderer - }) - - private val renderer = new NodeRenderer: - override def getNodeRenderingHandlers() = - java.util.HashSet(Arrays.asList(NodeRenderingHandler(classOf[Image], render _))) - private val whitelist = List( "imgur.com", @@ -140,44 +129,54 @@ object MarkdownRender: "i.ibb.co", "i.postimg.cc", "xkcd.com", - "lichess1.org", "images.prismic.io" ) - private def whitelistedSrc(src: String): Option[String] = for + + private def whitelistedSrc(src: String, assetDomain: String): Option[String] = for url <- Try(URL.parse(src)).toOption if url.scheme == "http" || url.scheme == "https" host <- Option(url.host).map(_.toHostString) - if whitelist.exists(h => host == h || host.endsWith(s".$h")) + if (assetDomain :: whitelist).exists(h => host == h || host.endsWith(s".$h")) yield url.toString - private def render(node: Image, context: NodeRendererContext, html: HtmlWriter): Unit = - // Based on implementation in CoreNodeRenderer. - if (context.isDoNotRenderLinks || CoreNodeRenderer.isSuppressedLinkPrefix(node.getUrl(), context)) - context.renderChildren(node) - else - { - val resolvedLink = context.resolveLink(LinkType.IMAGE, node.getUrl().unescape(), null, null) - val url = resolvedLink.getUrl() - val altText = new TextCollectingVisitor().collectAndGetText(node) - whitelistedSrc(url) match - case Some(src) => - html - .srcPos(node.getChars()) - .attr("src", src) - .attr("alt", altText) - .attr(resolvedLink.getNonNullAttributes()) - .withAttr(resolvedLink) - .tagVoid("img") - case None => - html - .srcPos(node.getChars()) - .attr("href", url) - .attr("rel", rel) - .withAttr(resolvedLink) - .tag("a") - .text(altText) - .tag("/a") - }.unit + def create(assetDomain: String) = new HtmlRenderer.HtmlRendererExtension: + override def rendererOptions(options: MutableDataHolder) = () + override def extend(htmlRendererBuilder: HtmlRenderer.Builder, rendererType: String) = + htmlRendererBuilder + .nodeRendererFactory(new NodeRendererFactory { + override def apply(options: DataHolder) = new NodeRenderer: + override def getNodeRenderingHandlers() = + Set(NodeRenderingHandler(classOf[Image], render _)).asJava + }) + + private def render(node: Image, context: NodeRendererContext, html: HtmlWriter): Unit = + // Based on implementation in CoreNodeRenderer. + if (context.isDoNotRenderLinks || CoreNodeRenderer.isSuppressedLinkPrefix(node.getUrl(), context)) + context.renderChildren(node) + else + { + val resolvedLink = context.resolveLink(LinkType.IMAGE, node.getUrl().unescape(), null, null) + val url = resolvedLink.getUrl() + val altText = new TextCollectingVisitor().collectAndGetText(node) + whitelistedSrc(url, assetDomain) match + case Some(src) => + html + .srcPos(node.getChars()) + .attr("src", src) + .attr("alt", altText) + .attr(resolvedLink.getNonNullAttributes()) + .withAttr(resolvedLink) + .tagVoid("img") + case None => + html + .srcPos(node.getChars()) + .attr("href", url) + .attr("rel", rel) + .withAttr(resolvedLink) + .tag("a") + .text(altText) + .tag("/a") + }.unit private class GameEmbedExtension(expander: GameExpand) extends HtmlRenderer.HtmlRendererExtension: override def rendererOptions(options: MutableDataHolder) = () diff --git a/modules/ublog/src/main/Env.scala b/modules/ublog/src/main/Env.scala index efc6af5d52cdf..1d3bb2447a373 100644 --- a/modules/ublog/src/main/Env.scala +++ b/modules/ublog/src/main/Env.scala @@ -24,7 +24,7 @@ final class Env( mode: play.api.Mode ): - export net.{ assetBaseUrl, baseUrl, domain } + export net.{ assetBaseUrl, baseUrl, domain, assetDomain } private val colls = new UblogColls(db(CollName("ublog_blog")), db(CollName("ublog_post"))) diff --git a/modules/ublog/src/main/UblogMarkup.scala b/modules/ublog/src/main/UblogMarkup.scala index 82f2b5eff7c34..c17ae4b3b2456 100644 --- a/modules/ublog/src/main/UblogMarkup.scala +++ b/modules/ublog/src/main/UblogMarkup.scala @@ -12,7 +12,8 @@ final class UblogMarkup( baseUrl: config.BaseUrl, assetBaseUrl: config.AssetBaseUrl, cacheApi: CacheApi, - netDomain: config.NetDomain + netDomain: config.NetDomain, + assetDomain: config.AssetDomain )(using ec: Executor, scheduler: Scheduler, mode: Mode): import UblogMarkup.* @@ -29,7 +30,8 @@ final class UblogMarkup( blockQuote = true, code = true, table = true, - gameExpand = MarkdownRender.GameExpand(netDomain, pgnCache.getIfPresent).some + gameExpand = MarkdownRender.GameExpand(netDomain, pgnCache.getIfPresent).some, + assetDomain.value ) def apply(post: UblogPost) = cache.get((post.id, post.markdown)).map { html => From 6cb8ece88eb5f24d3a9983439942fd904c2f7bc3 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 22 May 2023 08:37:10 +0200 Subject: [PATCH 3/6] type AssetDomain in MarkdownRender --- modules/common/src/main/MarkdownRender.scala | 11 ++++++----- modules/ublog/src/main/UblogMarkup.scala | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/common/src/main/MarkdownRender.scala b/modules/common/src/main/MarkdownRender.scala index c742cd3a2236b..49a7ff48b938f 100644 --- a/modules/common/src/main/MarkdownRender.scala +++ b/modules/common/src/main/MarkdownRender.scala @@ -33,6 +33,7 @@ import com.vladsch.flexmark.util.misc.Extension import lila.base.RawHtml import com.vladsch.flexmark.html.renderer.ResolvedLink import chess.format.pgn.PgnStr +import lila.common.config.AssetDomain final class MarkdownRender( autoLink: Boolean = true, @@ -43,7 +44,7 @@ final class MarkdownRender( list: Boolean = false, code: Boolean = false, gameExpand: Option[MarkdownRender.GameExpand] = None, - assetDomain: String = "lichess1.org" + assetDomain: AssetDomain = AssetDomain("lichess1.org") ): private val extensions = java.util.ArrayList[Extension]() @@ -113,7 +114,7 @@ object MarkdownRender: private object WhitelistedImage: - private val whitelist = + private val whitelist = AssetDomain.from: List( "imgur.com", "giphy.com", @@ -132,14 +133,14 @@ object MarkdownRender: "images.prismic.io" ) - private def whitelistedSrc(src: String, assetDomain: String): Option[String] = for + private def whitelistedSrc(src: String, assetDomain: AssetDomain): Option[String] = for url <- Try(URL.parse(src)).toOption if url.scheme == "http" || url.scheme == "https" host <- Option(url.host).map(_.toHostString) - if (assetDomain :: whitelist).exists(h => host == h || host.endsWith(s".$h")) + if (assetDomain :: whitelist).exists(h => host == h.value || host.endsWith(s".$h")) yield url.toString - def create(assetDomain: String) = new HtmlRenderer.HtmlRendererExtension: + def create(assetDomain: AssetDomain) = new HtmlRenderer.HtmlRendererExtension: override def rendererOptions(options: MutableDataHolder) = () override def extend(htmlRendererBuilder: HtmlRenderer.Builder, rendererType: String) = htmlRendererBuilder diff --git a/modules/ublog/src/main/UblogMarkup.scala b/modules/ublog/src/main/UblogMarkup.scala index c17ae4b3b2456..847ff3e8ce33e 100644 --- a/modules/ublog/src/main/UblogMarkup.scala +++ b/modules/ublog/src/main/UblogMarkup.scala @@ -31,7 +31,7 @@ final class UblogMarkup( code = true, table = true, gameExpand = MarkdownRender.GameExpand(netDomain, pgnCache.getIfPresent).some, - assetDomain.value + assetDomain ) def apply(post: UblogPost) = cache.get((post.id, post.markdown)).map { html => From df391329694af748f61bc8889b16d602a217a673 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 22 May 2023 08:40:42 +0200 Subject: [PATCH 4/6] optional MarkdownRender asset domain --- modules/common/src/main/MarkdownRender.scala | 8 ++++---- modules/ublog/src/main/UblogMarkup.scala | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/common/src/main/MarkdownRender.scala b/modules/common/src/main/MarkdownRender.scala index 49a7ff48b938f..22f5c3541a9d0 100644 --- a/modules/common/src/main/MarkdownRender.scala +++ b/modules/common/src/main/MarkdownRender.scala @@ -44,7 +44,7 @@ final class MarkdownRender( list: Boolean = false, code: Boolean = false, gameExpand: Option[MarkdownRender.GameExpand] = None, - assetDomain: AssetDomain = AssetDomain("lichess1.org") + assetDomain: Option[AssetDomain] = None ): private val extensions = java.util.ArrayList[Extension]() @@ -133,14 +133,14 @@ object MarkdownRender: "images.prismic.io" ) - private def whitelistedSrc(src: String, assetDomain: AssetDomain): Option[String] = for + private def whitelistedSrc(src: String, assetDomain: Option[AssetDomain]): Option[String] = for url <- Try(URL.parse(src)).toOption if url.scheme == "http" || url.scheme == "https" host <- Option(url.host).map(_.toHostString) - if (assetDomain :: whitelist).exists(h => host == h.value || host.endsWith(s".$h")) + if (assetDomain.toList ::: whitelist).exists(h => host == h.value || host.endsWith(s".$h")) yield url.toString - def create(assetDomain: AssetDomain) = new HtmlRenderer.HtmlRendererExtension: + def create(assetDomain: Option[AssetDomain]) = new HtmlRenderer.HtmlRendererExtension: override def rendererOptions(options: MutableDataHolder) = () override def extend(htmlRendererBuilder: HtmlRenderer.Builder, rendererType: String) = htmlRendererBuilder diff --git a/modules/ublog/src/main/UblogMarkup.scala b/modules/ublog/src/main/UblogMarkup.scala index 847ff3e8ce33e..353499e0f1156 100644 --- a/modules/ublog/src/main/UblogMarkup.scala +++ b/modules/ublog/src/main/UblogMarkup.scala @@ -31,7 +31,7 @@ final class UblogMarkup( code = true, table = true, gameExpand = MarkdownRender.GameExpand(netDomain, pgnCache.getIfPresent).some, - assetDomain + assetDomain.some ) def apply(post: UblogPost) = cache.get((post.id, post.markdown)).map { html => From 30892e536011acc9bc4f1088a4906e196895cf96 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 22 May 2023 11:02:54 +0200 Subject: [PATCH 5/6] naming tweaks --- app/controllers/Main.scala | 6 +++--- app/views/ublog/form.scala | 2 +- ui/site/src/ublogForm.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/Main.scala b/app/controllers/Main.scala index 6a83aee5ec58d..8f8f125935cbb 100644 --- a/app/controllers/Main.scala +++ b/app/controllers/Main.scala @@ -202,8 +202,8 @@ Allow: / def devAsset(v: String, path: String, file: String) = assetsC.at(path, file) - private val ImageRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress]( - key = "user.image.ip" + private val ImageUploadRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress]( + key = "image.upload.ip" )( ("fast", 10, 2.minutes), ("slow", 60, 1.day) @@ -213,7 +213,7 @@ Allow: / if lila.common.HTTPRequest.isXhr(ctx.req) then ctx.body.body.file("image") match case Some(image) => - ImageRateLimitPerIp(ctx.ip, rateLimitedFu): + ImageUploadRateLimitPerIp(ctx.ip, rateLimitedFu): env.memo.picfitApi .uploadFile( s"userimage:${ornicar.scalalib.ThreadLocalRandom.nextString(12)}", diff --git a/app/views/ublog/form.scala b/app/views/ublog/form.scala index 04e4f8fce6cfb..d950e4ca90638 100644 --- a/app/views/ublog/form.scala +++ b/app/views/ublog/form.scala @@ -141,7 +141,7 @@ object form: ) { field => frag( form3.textarea(field)(), - div(id := "markdown-editor", attr("data-url") := routes.Main.uploadImage) + div(id := "markdown-editor", attr("data-image-upload-url") := routes.Main.uploadImage) ) }, post.toOption match { diff --git a/ui/site/src/ublogForm.ts b/ui/site/src/ublogForm.ts index 658c0dc94bfbe..7af09ac2cbe95 100644 --- a/ui/site/src/ublogForm.ts +++ b/ui/site/src/ublogForm.ts @@ -76,7 +76,7 @@ const setupMarkdownEditor = (el: HTMLTextAreaElement) => { const formData = new FormData(); formData.append('image', blob); xhr - .json(el.getAttribute('data-url')!, { method: 'POST', body: formData }) + .json(el.getAttribute('data-image-upload-url')!, { method: 'POST', body: formData }) .then(data => cb(data.imageUrl, '')) .catch(e => { cb(''); From b7dbd8cd51175c2b49e9e616d5c673a3e91e7d9b Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 22 May 2023 11:21:36 +0200 Subject: [PATCH 6/6] set ublog image rel can't use the ublog post id, as it might not yet exist --- app/controllers/Main.scala | 4 ++-- app/views/ublog/form.scala | 6 +++--- conf/routes | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/Main.scala b/app/controllers/Main.scala index 8f8f125935cbb..c9ab48e26932c 100644 --- a/app/controllers/Main.scala +++ b/app/controllers/Main.scala @@ -209,14 +209,14 @@ Allow: / ("slow", 60, 1.day) ) - def uploadImage = AuthBody(parse.multipartFormData) { ctx ?=> me => + def uploadImage(rel: String) = AuthBody(parse.multipartFormData) { ctx ?=> me => if lila.common.HTTPRequest.isXhr(ctx.req) then ctx.body.body.file("image") match case Some(image) => ImageUploadRateLimitPerIp(ctx.ip, rateLimitedFu): env.memo.picfitApi .uploadFile( - s"userimage:${ornicar.scalalib.ThreadLocalRandom.nextString(12)}", + s"$rel:${ornicar.scalalib.ThreadLocalRandom.nextString(12)}", image, userId = me.id ) diff --git a/app/views/ublog/form.scala b/app/views/ublog/form.scala index d950e4ca90638..9213ad61a607f 100644 --- a/app/views/ublog/form.scala +++ b/app/views/ublog/form.scala @@ -112,8 +112,8 @@ object form: def formImage(post: UblogPost) = postView.thumbnail(post, _.Small)(cls := post.image.isDefined.option("user-image")) - private def inner(form: Form[UblogPostData], post: Either[User, UblogPost], captcha: Option[Captcha])( - implicit ctx: Context + private def inner(form: Form[UblogPostData], post: Either[User, UblogPost], captcha: Option[Captcha])(using + Context ) = postForm( cls := "form3 ublog-post-form__main", @@ -141,7 +141,7 @@ object form: ) { field => frag( form3.textarea(field)(), - div(id := "markdown-editor", attr("data-image-upload-url") := routes.Main.uploadImage) + div(id := "markdown-editor", attr("data-image-upload-url") := routes.Main.uploadImage("ublogBody")) ) }, post.toOption match { diff --git a/conf/routes b/conf/routes index 58cbf2165279b..a8c71379a828b 100644 --- a/conf/routes +++ b/conf/routes @@ -815,7 +815,7 @@ GET /verify-title controllers.Main.verifyTitle GET /InstantChess.com controllers.Main.instantChess GET /daily-puzzle-slack controllers.Main.dailyPuzzleSlackApp GET /temporarily-disabled controllers.Main.temporarilyDisabled -POST /upload/image/user controllers.Main.uploadImage +POST /upload/image/user/:rel controllers.Main.uploadImage(rel) # Dev GET /dev/cli controllers.Dev.cli