diff --git a/app/controllers/Main.scala b/app/controllers/Main.scala index 15ea4fbde27a1..c9ab48e26932c 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 ImageUploadRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress]( + key = "image.upload.ip" + )( + ("fast", 10, 2.minutes), + ("slow", 60, 1.day) + ) + + 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"$rel:${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..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") + 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 db63d6bbcf744..a8c71379a828b 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/:rel controllers.Main.uploadImage(rel) # Dev GET /dev/cli controllers.Dev.cli diff --git a/modules/common/src/main/MarkdownRender.scala b/modules/common/src/main/MarkdownRender.scala index b8d04e79bbae1..22f5c3541a9d0 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, @@ -42,7 +43,8 @@ final class MarkdownRender( blockQuote: Boolean = false, list: Boolean = false, code: Boolean = false, - gameExpand: Option[MarkdownRender.GameExpand] = None + gameExpand: Option[MarkdownRender.GameExpand] = None, + assetDomain: Option[AssetDomain] = None ): private val extensions = java.util.ArrayList[Extension]() @@ -52,7 +54,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,19 +114,7 @@ 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 = + private val whitelist = AssetDomain.from: List( "imgur.com", "giphy.com", @@ -140,44 +130,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: 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 whitelist.exists(h => host == h || host.endsWith(s".$h")) + if (assetDomain.toList ::: whitelist).exists(h => host == h.value || 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: Option[AssetDomain]) = 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..353499e0f1156 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.some ) def apply(post: UblogPost) = cache.get((post.id, post.markdown)).map { html => 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..7af09ac2cbe95 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-image-upload-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());