Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/controllers/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 3 additions & 3 deletions app/views/ublog/form.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 46 additions & 46 deletions modules/common/src/main/MarkdownRender.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]()
Expand All @@ -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(_) }
)
Expand Down Expand Up @@ -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",
Expand All @@ -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) = ()
Expand Down
2 changes: 1 addition & 1 deletion modules/ublog/src/main/Env.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")))

Expand Down
6 changes: 4 additions & 2 deletions modules/ublog/src/main/UblogMarkup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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 =>
Expand Down
5 changes: 0 additions & 5 deletions ui/site/css/ublog/_form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,5 @@
display: none;
}
}
&-popup-add-image {
.toastui-editor-tabs {
display: none;
}
}
}
}
19 changes: 11 additions & 8 deletions ui/site/src/ublogForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const setupImage = (form: HTMLFormElement) => {

const setupMarkdownEditor = (el: HTMLTextAreaElement) => {
const postProcess = (markdown: string) => markdown.replace(/<br>/g, '').replace(/\n\s*#\s/g, '\n## ');

const editor: Editor = new Editor({
el,
usageStatistics: false,
Expand All @@ -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;
});
},
},
});
Expand All @@ -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());
Expand Down