Hardening Markdown Security0:00
Let's begin by tackling the security concerns surrounding the Markdown helper. So under the hood, Laravel is using a popular package called CommonMark, and in the CommonMark docs, under security, it makes clear that there are a few options you might want to consider setting. Now technically, Laravel uses the GitHub flavoured Markdown, which is slightly different in that it already stops certain HTML being allowed, but I think it's important just to set these so that we're absolutely certain we're explicit in the fact that we don't want to allow HTML in our Markdown. So let's go ahead, and using an associative array passed to the Markdown helper, we can start with HTML input, which we'll want to set to strip, strip any HTML out of the output.
So let's go ahead, and using an associative array passed to the Markdown helper, we can start with HTML input, which we'll want to set to strip, strip any HTML out of the output. We'll want to set allowUnsafeLinks to false. An unsafe link is anything like the JavaScript protocol, or the VBScript protocol, anything that could have adverse side effects when loaded into a website. And then we'll have maxNestingLevel, and I'm going to set this to 5. MaxNestingLevel is how many times you can nest the Markdown, so how many times can you have a blockquote, in a blockquote, in a blockquote, in a blockquote. Because technically, somebody could use that to make our server have huge load in doing the conversion.
Extracting Conversion Trait1:20
Because technically, somebody could use that to make our server have huge load in doing the conversion. They could even potentially do a DDoS attack using nothing more than Markdown conversion. We definitely don't want to allow that. So go ahead, set a sensible maximum nesting level. Okay, with this in place, we can start thinking about porting the functionality of converting Markdown to HTML from posts over to comments, which we also want to have Markdown functionality. But I don't just want to copy and paste, because then I have to maintain this across multiple models. It will make much more sense to make it more generic and have it available as a concern
multiple models. It will make much more sense to make it more generic and have it available as a concern or a trait available to any model. So tell you what, let's cut this booted method out. And let's go ahead and in the models directory, we'll create a new subdirectory called concerns. And that terminology, by the way, comes from the Laravel framework itself. So I'll stick with that convention. And then we'll create a new trait, and we'll call this ConvertsMarkdownToHTML. And then we can paste the booted method that we had in our Post into this trait. And so that we don't have to call this manually, I'm actually going to copy the name of the
And then we can paste the booted method that we had in our Post into this trait. And so that we don't have to call this manually, I'm actually going to copy the name of the trait. I'm going to change booted to boot, and then I'll paste the trait name after. So we end up with boot convertsMarkdownToHTML. And this convention allows Laravel to automatically call this method on your model for you. You don't have to do anything manually other than include this trait in the model you're using. And then let's make this generic. So we'll set Post to model instead.
Adding Comment Markdown Support3:00
And then let's make this generic. So we'll set Post to model instead. We'll head back to our Post, and we'll say use convertsMarkdownToHTML. Okay, let's now go into the postTest that we have, and I'm going to rerun it generates HTML to make sure we haven't broken anything. Yep, it all still works, which is wonderful news. Let's take this test, we'll copy it, and we'll create a new model test, which we'll call the CommentTest, right? CommentTest.php. We're going to have our opening php tags, paste this in.
Comment test.php. We're going to have our opening PHP tags, paste this in. We're not interested in a Post, we want a Comment instead. And everything else should work. Let's rename this to Comment as well. And then we'll run this test. Now, obviously, at the moment, it fails. Primarily, it fails because on the comments table, we don't even have a HTML column. So let's add that HTML. Then on the Comment model itself, we can add our new concern on new trait.
So let's add that HTML. Then on the Comment model itself, we can add our new concern on a new trait. So use converts Markdown to HTML, and then we'll rerun that test. And now it passes. Wonderful. We could leave this here because obviously, it is going to work for both our Comment and Post models. But I'd like to make it a little more generic, because currently, you're required to have two columns on a model called body and HTML. You also can only have one set of conversions per model.
Generalizing Column Conversion Map4:19
two columns on a model called body and HTML. You also can only have one set of conversions per model. But we might have multiple columns that we want to convert from Markdown to HTML. So I think it would be worth just factoring that in, we could have, for example, a protected static function, and we'll call it getMarkdownToHtmlMap, which is an array. And it will return by default, an array that will map from the body column to the HTML column. But as you can imagine, if we wanted to override that, we could absolutely do so at the model level, allowing us to support multiple conversions or just change the name of the default columns. Okay, once we have that in place, we're going to need to loop over that array.
level, allowing us to support multiple conversions or just change the name of the default columns. Okay, once we have that in place, we're going to need to loop over that array. So let's convert the arrow function here to a traditional closure. And we'll collect up self map, get Markdown to HTML map. And just looking at how we actually want to finalize this, or maybe what we could do is flip the keys and the values. So let's use the flip method for that. And then once we have that, we can map. So what we're going to be receiving is basically this column name here, which is the body column name.
So what we're going to be receiving is basically this column name here, which is the body column name. And so we'll say body column. And we want to return string, wrapping the model, and then the body column. And then we'll call the Markdown method, and we'll pass in our security options to that Markdown method, like so. Okay, and once we have that in place, well, I'll set that as the Markdown data. And then inside model fill, I'm going to pass Markdown data, and I'll call the all method to convert this to an array, because fill expects an array, not a collection. Okay, I wonder if this will work.
to convert this to an array, because fill expects an array, not a collection. Okay, I wonder if this will work. Let's go ahead and run all of our model tests. And hopefully, yep, all five of them pass, meaning that this is a successful refactor. And although we're not making use of the functionality that we've just provided in getMarkdownToHtmlMap just yet, no doubt it will come in useful at some point in the future. Okay, what's next? So here we are in the show page for a Post where we have our comments output. And where we have this text area here currently, why don't we just switch that out with a Markdown editor?
Enhancing Markdown Editor UI6:40
And where we have this text area here currently, why don't we just switch that out with a Markdown editor? We still need reference so that we can refocus the editor. We'll come back to that in a moment. And we can get rid of rows because that's not important. And we'll have our placeholder, although that's not going to work out of the box. We'll fix that in a moment as well. But if we come back to the front end, we now have a Markdown editor. It's much too tall. So why don't we add support for specifying editor classes?
It's much too tall. So why don't we add support for specifying editor classes? So ideally we'd say editorClass equals, and then we could set something like minHeight of, I don't know, 160 pixels. So in order to support this, let's add a prop. Here we have props. I will say editorClass. And by default, that's just an empty string. And then where we set the attributes down here, I'll use backtick syntax in order to introduce the props.editorClass property where it makes sense.
And then where we set the attributes down here, I'll use backtick syntax in order to introduce the props.editor class property where it makes sense. Just tagging it here on the end. And yeah, there's the result. You can see we have our comment field now, which is much smaller, much more akin to what we had before when we just showed the textarea. But if we go to create a Post, we still have this nice, large, what you see is what you get editor here. And by the way, we could remove this textarea now, right? So let's go to the create page and let's get rid of the textarea from there.
And by the way, we could remove this text area now, right? So let's go to the create page and let's get rid of the text area from there. Yeah, much better. Okay. Back to the Post in question. We need to see a placeholder. We don't currently see a placeholder. And TipTap does support placeholders, but not in the standard way. You have to add support through a plugin, but that's fairly straightforward. We know how this works now.
You have to add support through a plugin, but that's fairly straightforward. We know how this works now. Let's go ahead and run npm install TipTap extension placeholder. While that's installing, let's see what we have to do. We obviously have to add the plugin itself. Looks like we have to add some CSS as well. Rather than adding this globally, I'm going to add it to style tags inside the component. So we'll have style. I'll make it scoped so that it only applies to this component and not to the entire DOM. And we'll drop that in place.
I'll make it scoped so that it only applies to this component and not to the entire DOM. And we'll drop that in place. And I'm going to have to use the deep helper here, which basically says, don't just target the top level element, target anything that's below it as well, which is important because the TipTap editor P tag, for example, is lower. Once we have that in place, we could actually convert some of this to Tailwind using @apply. So we'll say text-gray-400, and we could say float-left, we could say height-0, pointer-events-none. I'm going to keep content as its own CSS property.
events none. I'm going to keep content as its own CSS property. Get rid of the rest of this. Yeah, that should work nicely. And now that we have everything installed, I can make use of the placeholder.configure method. So placeholder.configured. And in here, I'm pretty sure I can set the placeholder like so. So perhaps inside our props, we also accept a placeholder, which by default can be null. And then we'll set props.placeholder here.
So perhaps inside our props, we also accept a placeholder, which by default can be null. And then we'll set props.placeholder here. And I think that should be enough to see this working. Let's go back to the front end. Yeah, there you go. You can see our placeholder that disappears once we start typing. But again, on create a Post, we don't see that placeholder. Very nice. Let's add a little bit of formatting to a Comment so that we can see it actually appear in the front end.
Rendering Comment HTML Output9:58
Let's add a little bit of formatting to a Comment so that we can see it actually appear in the front end. I really enjoyed this review. Nice job. And let's make nice job all bold, like so. Hit addComment. And yeah, we're currently seeing it in markdown, but we already know the fix for that. Let's go into our little Comment component. And inside here, we have commentBody in a p tag currently. Instead, I'm going to put this in a div.
And inside here, we have comment.body in a <p> tag currently. Instead, I'm going to put this in a <div>. We'll still have mt-1. I don't need break-all. Instead, I'll add prose, prose-sm, and max-w-none again. And once I have that in place, I can set the v-html to comment.html rather than comment.body. And hopefully, yeah, of course, if we go to the comment resource, we actually need to add this to the output. So let's copy and paste the body column. But instead, we're going to be outputting the HTML column.
So let's copy and paste the body column. But instead, we're going to be outputting the HTML column. And yeah, that's much better. We now see this in HTML, nicely formatted thanks to Tailwind's typography plugin in the front end. So we have formatted posts and formatted comments. And I think edit should just work. If I click edit, yep, it appears here. I can make this uppercase because I made a mistake. Update the comment, confirm.
Fixing Editor Refocus11:18
I can make this uppercase because I made a mistake. Update the comment, confirm. And we see that reflected in the output down here. Awesome. One thing I did notice when I clicked edit is it's not refocusing my editor. So that's obviously broken. We need to fix it. In order to do that, let's go to the markdown editor. And under the editor, I'm going to define an expose. So this is any properties that will be available to references to this component in the parent.
And under the editor, I'm going to define an expose. So this is any properties that will be available to references to this component in the parent. That might sound a bit confusing. Let's just do it by example. So I'm going to pass an object to define expose. And I'm going to say you have access to a focus property or rather method in this case on the child component. Whenever you call it, we're going to actually execute this closure here. And this closure will grab the value of the editor. It will grab the commands available to it.
And this closure will grab the value of the editor. It will grab the commands available to it. And it will call the focus command like so. So now if we jump into the post show page, we already have reference to the markdown editor. And the markdown editor is used further down here to focus, which is exactly what we've defined as the expose. So this should just work. Let's see if that's the case. We'll go back to the editor.
Let's see if that's the case. We'll go back to the editor. I'll click edit. You can see it did indeed refocus as we expected. Let's try again. Update the comment. It should also refocus when I click cancel. You can see that it absolutely does. And you can put anything inside here, by the way. Let's do like alert('hello'), just to show you that this works.
And you can put anything inside here, by the way. Let's do like alert hello, just to show you that this works. Click edit. And now instead of refocusing the editor, it actually alert and says hello. So yeah, use with caution. Make sure you're doing what is expected. And it's nice that you can actually provide an API from child components to parent components if that makes sense in view. So there we go. We have a working, powerful, extensible, easy to use, what you see is what you get markdown.
So there we go. We have a working, powerful, extensible, easy to use, what you see is what you get markdown editor that is used for both posts and comments. But thanks to that concern, that trait that we created could be used anywhere in any model in our project where we see fit. Thanks to the Tailwind typography plugin, we have nice, gorgeous output that has really transformed the look of our site, both in posts and in the comments below those posts. I think it's about time we moved on.
