mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	fix(backend): 🐛 Grab PRs from dev branch (#826)
* fix(backend): 🐛 Grab PR #780 * feat(frontend): ✨ Grab PR 797 * docs(docs): spelling * feat(backend): ✨ Add LDAP Support from #803 * add test deps Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
		
							
								
								
									
										1
									
								
								.github/workflows/backend-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/backend-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -55,6 +55,7 @@ jobs: | ||||
|       #---------------------------------------------- | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           sudo apt-get install libsasl2-dev libldap2-dev libssl-dev | ||||
|           poetry install | ||||
|           poetry add "psycopg2-binary==2.8.6" | ||||
|         # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' | ||||
|   | ||||
| @@ -17,6 +17,8 @@ | ||||
| | TZ            |          UTC          | Must be set to get correct date/time on the server                                  | | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Database | ||||
|  | ||||
| | Variables         | Default  | Description                      | | ||||
| @@ -49,3 +51,13 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea | ||||
| | WORKERS_PER_CORE |    1    | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] | | ||||
| | MAX_WORKERS      |    1    | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers]                     | | ||||
| | WEB_CONCURRENCY  |    1    | Override the automatic definition of number of workers. More info [here][web_concurrency]                                         | | ||||
|  | ||||
|  | ||||
| ### LDAP | ||||
|  | ||||
| | Variables          | Default | Description                                                                                                        | | ||||
| | ------------------ | :-----: | ------------------------------------------------------------------------------------------------------------------ | | ||||
| | LDAP_AUTH_ENABLED  |  False  | Authenticate via an external LDAP server in addidion to built-in Mealie auth                                       | | ||||
| | LDAP_SERVER_URL    |  None   | LDAP server URL (e.g. ldap://ldap.example.com)                                                                     | | ||||
| | LDAP_BIND_TEMPLATE |  None   | Templated DN for users, `{}` will be replaced with the username (e.g. `cn={},dc=example,dc=com`)                   | | ||||
| | LDAP_ADMIN_FILTER  |  None   | Optional LDAP filter, which tells Mealie the LDAP user is an admin (e.g. `(memberOf=cn=admins,dc=example,dc=com)`) | | ||||
| @@ -1,355 +1,421 @@ | ||||
| <!-- Custom HTML site displayed as the Home chapter --> | ||||
|  | ||||
| {% extends "main.html" %} | ||||
| {% block tabs %} | ||||
| {{ super() }} | ||||
| {% extends "main.html" %} {% block tabs %} {{ super() }} | ||||
| <style> | ||||
|   .md-main { | ||||
|     flex-grow: 0; | ||||
|   } | ||||
|  | ||||
| .md-main { | ||||
|         flex-grow: 0 | ||||
|   .md-main__inner { | ||||
|     display: flex; | ||||
|     height: 100%; | ||||
|   } | ||||
|  | ||||
|   .tx-container { | ||||
|     padding-top: 0rem; | ||||
|   } | ||||
|  | ||||
|   .tx-hero { | ||||
|     margin: 12px 2.8rem; | ||||
|     justify-content: center; | ||||
|   } | ||||
|  | ||||
|   .tx-hero h1 { | ||||
|     margin-bottom: 1rem; | ||||
|     font-family: "Roboto"; | ||||
|     color: var(--md-custom-h2-color); | ||||
|     font-weight: 500; | ||||
|   } | ||||
|  | ||||
|   .tx-hero__content { | ||||
|     padding-bottom: 1rem; | ||||
|     margin: 0 auto; | ||||
|   } | ||||
|  | ||||
|   .tx-hero__image { | ||||
|     order: 1; | ||||
|     padding-right: 2.5rem; | ||||
|   } | ||||
|  | ||||
|   .tx-hero .md-button { | ||||
|     margin-top: 0.5rem; | ||||
|     margin-right: 0.5rem; | ||||
|     color: var(--md-primary-fg-color); | ||||
|   } | ||||
|  | ||||
|   .tx-hero .md-button--primary { | ||||
|     background-color: var(--md-primary--color); | ||||
|     border-color: var(--md-primary-bg-color); | ||||
|   } | ||||
|  | ||||
|   .tx-hero .md-button:focus, | ||||
|   .tx-hero .md-button:hover { | ||||
|     background-color: var(--md-accent-fg-color); | ||||
|     color: var(--md-default-bg-color); | ||||
|     border-color: var(--md-accent-fg-color); | ||||
|   } | ||||
|  | ||||
|   .feature-item h2 svg { | ||||
|     height: 30px; | ||||
|     float: left; | ||||
|     margin-right: 10px; | ||||
|     transform: translateY(10%); | ||||
|   } | ||||
|  | ||||
|   .feature-container { | ||||
|     background-color: var(--md-default-accent-bg-color); | ||||
|   } | ||||
|  | ||||
|   .top-hr { | ||||
|     margin-top: 42px; | ||||
|     margin-bottom: 42px; | ||||
|   } | ||||
|  | ||||
|   .feature-item { | ||||
|     font-family: "Lato", sans-serif; | ||||
|     font-weight: 300; | ||||
|     box-sizing: border-box; | ||||
|     padding: 0 15px; | ||||
|     word-break: break-word; | ||||
|   } | ||||
|  | ||||
|   .feature-item h2 { | ||||
|     color: var(--md-custom-h2-color); | ||||
|     font-weight: 300; | ||||
|     font-size: 25px; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     line-height: normal; | ||||
|     margin-top: 20px; | ||||
|     margin-bottom: 10px; | ||||
|     font-family: inherit; | ||||
|   } | ||||
|  | ||||
|   .feature-item p { | ||||
|     font-size: 16px; | ||||
|     line-height: 1.8em; | ||||
|     text-rendering: optimizeLegibility; | ||||
|     -webkit-font-smoothing: antialiased; | ||||
|     color: var(--webkit-print-color-adjust); | ||||
|     margin: 0 0 10px; | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   @media screen and (max-width: 30em) { | ||||
|     .tx-hero h1 { | ||||
|       font-size: 1.4rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|     .md-main__inner { | ||||
|         display: flex; | ||||
|         height: 100%; | ||||
|     } | ||||
|  | ||||
|     .tx-container { | ||||
|         padding-top: .0rem; | ||||
|   @media screen and (min-width: 60em) { | ||||
|     .md-sidebar--secondary { | ||||
|       display: none; | ||||
|     } | ||||
|  | ||||
|     .tx-hero { | ||||
|         margin: 12px 2.8rem; | ||||
|         justify-content: center; | ||||
|     } | ||||
|  | ||||
|     .tx-hero h1 { | ||||
|         margin-bottom: 1rem; | ||||
|         font-family: "Roboto"; | ||||
|         color: var(--md-custom-h2-color); | ||||
|         font-weight: 500 | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|     } | ||||
|  | ||||
|     .tx-hero__content { | ||||
|         padding-bottom: 1rem; | ||||
|         margin: 0 auto; | ||||
|       max-width: 22rem; | ||||
|       margin-top: 3.5rem; | ||||
|       margin-bottom: 3.5rem; | ||||
|       margin-left: 1rem; | ||||
|       margin-right: 4rem; | ||||
|       align-items: center; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|     .tx-hero__image{ | ||||
|  | ||||
|         order:1; | ||||
|         padding-right: 2.5rem; | ||||
|     } | ||||
|  | ||||
|     .tx-hero .md-button { | ||||
|         margin-top: .5rem; | ||||
|         margin-right: .5rem; | ||||
|         color: var(--md-primary-fg-color) | ||||
|     } | ||||
|  | ||||
|     .tx-hero .md-button--primary { | ||||
|         background-color: var(--md-primary--color); | ||||
|         border-color: var(--md-primary-bg-color) | ||||
|     } | ||||
|  | ||||
|     .tx-hero .md-button:focus, | ||||
|     .tx-hero .md-button:hover { | ||||
|         background-color: var(--md-accent-fg-color); | ||||
|         color: var(--md-default-bg-color); | ||||
|         border-color: var(--md-accent-fg-color) | ||||
|     } | ||||
|  | ||||
|     .feature-item h2 svg { | ||||
|         height: 30px; | ||||
|         float: left; | ||||
|         margin-right: 10px; | ||||
|         transform: translateY(10%); | ||||
|     } | ||||
|  | ||||
|     .feature-container { | ||||
|         background-color: var(--md-default-accent-bg-color); | ||||
|   @media screen and (min-width: 76.25em) { | ||||
|     .md-sidebar--primary { | ||||
|       display: none; | ||||
|     } | ||||
|  | ||||
|     .top-hr { | ||||
|         margin-top: 42px; | ||||
|         margin-bottom: 42px; | ||||
|       width: 100%; | ||||
|       display: flex; | ||||
|       max-width: 61rem; | ||||
|       margin-right: auto; | ||||
|       margin-left: auto; | ||||
|       padding: 0 0.2rem; | ||||
|     } | ||||
|  | ||||
|     .bottom-hr { | ||||
|       margin-top: 10px; | ||||
|       width: 100%; | ||||
|       display: flex; | ||||
|       max-width: 61rem; | ||||
|       margin-right: auto; | ||||
|       margin-left: auto; | ||||
|       padding: 0 0.2rem; | ||||
|     } | ||||
|  | ||||
|     .feature-item { | ||||
|         font-family: 'Lato', sans-serif; | ||||
|         font-weight: 300; | ||||
|         box-sizing: border-box; | ||||
|         padding: 0 15px; | ||||
|         word-break: break-word | ||||
|       flex: 1; | ||||
|       min-width: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|     .feature-item h2 { | ||||
|         color: var(--md-custom-h2-color); | ||||
|         font-weight: 300; | ||||
|         font-size: 25px; | ||||
|         white-space: nowrap; | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|         line-height: normal; | ||||
|         margin-top: 20px; | ||||
|         margin-bottom: 10px; | ||||
|         font-family: inherit; | ||||
|     } | ||||
|   .hr { | ||||
|     border-bottom: 1px solid #eee; | ||||
|     width: 100%; | ||||
|     margin: 20px 0; | ||||
|   } | ||||
|  | ||||
|     .feature-item p { | ||||
|         font-size: 16px; | ||||
|         line-height: 1.8em; | ||||
|         text-rendering: optimizeLegibility; | ||||
|         -webkit-font-smoothing: antialiased; | ||||
|         color: var(--webkit-print-color-adjust); | ||||
|         margin: 0 0 10px; | ||||
|         display: block; | ||||
|     } | ||||
|   .text-center { | ||||
|     text-align: center; | ||||
|     padding-right: 15px; | ||||
|     padding-left: 15px; | ||||
|     margin-right: auto; | ||||
|     margin-left: auto; | ||||
|     margin-top: 15px; | ||||
|     font-family: "Lato", sans-serif; | ||||
|     font-size: 23px; | ||||
|     font-weight: 300; | ||||
|     padding-bottom: 10px; | ||||
|   } | ||||
|  | ||||
|     @media screen and (max-width:30em) { | ||||
|         .tx-hero h1 { | ||||
|             font-size: 1.4rem | ||||
|         } | ||||
|     } | ||||
|   .logos { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     flex-flow: row wrap; | ||||
|     margin: 0 auto; | ||||
|   } | ||||
|  | ||||
|     @media screen and (min-width:60em) { | ||||
|         .md-sidebar--secondary { | ||||
|             display: none | ||||
|         } | ||||
|   .logos img { | ||||
|     flex: 1 1 auto; | ||||
|     padding: 25px; | ||||
|     max-height: 130px; | ||||
|     vertical-align: middle; | ||||
|   } | ||||
|  | ||||
|         .tx-hero { | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|         } | ||||
|   .hr-logos { | ||||
|     margin-top: 0; | ||||
|     margin-bottom: 30px; | ||||
|   } | ||||
|  | ||||
|         .tx-hero__content { | ||||
|             max-width: 22rem; | ||||
|             margin-top: 3.5rem; | ||||
|             margin-bottom: 3.5rem; | ||||
|             margin-left: 1.0rem; | ||||
|             margin-right: 4.0rem; | ||||
|             align-items: center; | ||||
|         } | ||||
|     } | ||||
|   .md-footer-meta__inner { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     justify-content: space-between; | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
|  | ||||
|     @media screen and (min-width:76.25em) { | ||||
|         .md-sidebar--primary { | ||||
|             display: none | ||||
|         } | ||||
|  | ||||
|         .top-hr { | ||||
|             width: 100%; | ||||
|             display: flex; | ||||
|             max-width: 61rem; | ||||
|             margin-right: auto; | ||||
|             margin-left: auto; | ||||
|             padding: 0 .2rem; | ||||
|         } | ||||
|  | ||||
|         .bottom-hr { | ||||
|             margin-top: 10px; | ||||
|             width: 100%; | ||||
|             display: flex; | ||||
|             max-width: 61rem; | ||||
|             margin-right: auto; | ||||
|             margin-left: auto; | ||||
|             padding: 0 .2rem; | ||||
|         } | ||||
|  | ||||
|         .feature-item { | ||||
|             flex: 1; | ||||
|             min-width: 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     .hr { | ||||
|         border-bottom: 1px solid #eee; | ||||
|         width: 100%; | ||||
|         margin: 20px 0; | ||||
|     } | ||||
|  | ||||
|     .text-center { | ||||
|         text-align: center; | ||||
|         padding-right: 15px; | ||||
|         padding-left: 15px; | ||||
|         margin-right: auto; | ||||
|         margin-left: auto; | ||||
|         margin-top: 15px; | ||||
|         font-family: 'Lato', sans-serif; | ||||
|         font-size: 23px; | ||||
|         font-weight: 300; | ||||
|         padding-bottom: 10px; | ||||
|     } | ||||
|  | ||||
|     .logos { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         flex-flow: row wrap; | ||||
|         margin: 0 auto; | ||||
|     } | ||||
|  | ||||
|     .logos img { | ||||
|         flex: 1 1 auto; | ||||
|         padding: 25px; | ||||
|         max-height: 130px; | ||||
|         vertical-align: middle; | ||||
|     } | ||||
|  | ||||
|     .hr-logos { | ||||
|         margin-top: 0; | ||||
|         margin-bottom: 30px; | ||||
|     } | ||||
|  | ||||
|     .md-footer-meta__inner { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|         justify-content: space-between; | ||||
|         margin-top: 1.0rem; | ||||
|     } | ||||
|  | ||||
|     .md-footer-social { | ||||
|         padding-top: 20px; | ||||
|     } | ||||
|   .md-footer-social { | ||||
|     padding-top: 20px; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <!-- Main site Entry button descriptions --> | ||||
| <section class="tx-container"> | ||||
|     <div class="md-grid md-typeset"> | ||||
|       <div class="tx-hero"> | ||||
|         <div class="tx-hero__image"> | ||||
|           <img src="assets/img/home_screenshot.png" draggable="false"> | ||||
|         </div> | ||||
|         <div class="tx-hero__content"> | ||||
|           <h1>  | ||||
|             Mealie  | ||||
|         </h1> | ||||
|           <p> | ||||
|             A self-hosted recipe manager and meal planner with a RestAPI backend and a  | ||||
|             reactive frontend application built in Vue for a pleasant user experience for the  | ||||
|             whole family. | ||||
|           </p> | ||||
|           <a href="{{ page.next_page.url | url }}" title="{{ page.next_page.title | striptags }}" class="md-button md-button--primary"> | ||||
|             Get started | ||||
|           </a> | ||||
|           <a href="{{ config.demo_url }}" title="{{ lang.t('source.link.title') }}" target="_blank" class="md-button"> | ||||
|             View the Demo | ||||
|           </a> | ||||
|         </div> | ||||
|   <div class="md-grid md-typeset"> | ||||
|     <div class="tx-hero"> | ||||
|       <div class="tx-hero__image"> | ||||
|         <img src="assets/img/home_screenshot.png" draggable="false" /> | ||||
|       </div> | ||||
|       <div class="tx-hero__content"> | ||||
|         <h1>Mealie</h1> | ||||
|         <p> | ||||
|           A self-hosted recipe manager and meal planner with a RestAPI backend | ||||
|           and a reactive frontend application built in Vue for a pleasant user | ||||
|           experience for the whole family. | ||||
|         </p> | ||||
|         <a | ||||
|           href="{{ page.next_page.url | url }}" | ||||
|           title="{{ page.next_page.title | striptags }}" | ||||
|           class="md-button md-button--primary" | ||||
|         > | ||||
|           Get started | ||||
|         </a> | ||||
|         <a | ||||
|           href="{{ config.demo_url }}" | ||||
|           title="{{ lang.t('source.link.title') }}" | ||||
|           target="_blank" | ||||
|           class="md-button" | ||||
|         > | ||||
|           View the Demo | ||||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </section> | ||||
|  | ||||
| <!-- Main site box descriptions --> | ||||
| <!-- Row 1 --> | ||||
| <section class="feature-container"> | ||||
|   <div class="top-hr"> | ||||
|     <div class="feature-item"> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,4.5L8.95,4.53C8.71,5.05 8.34,5.93 8.07,7H10.93C10.66,5.93 10.29,5.05 10.05,4.53C9.87,4.5 9.69,4.5 9.5,4.5M13.83,7C13.24,5.97 12.29,5.17 11.15,4.78C11.39,5.31 11.7,6.08 11.93,7H13.83M5.17,7H7.07C7.3,6.08 7.61,5.31 7.85,4.78C6.71,5.17 5.76,5.97 5.17,7M4.5,9.5C4.5,10 4.58,10.53 4.73,11H6.87L6.75,9.5L6.87,8H4.73C4.58,8.47 4.5,9 4.5,9.5M14.27,11C14.42,10.53 14.5,10 14.5,9.5C14.5,9 14.42,8.47 14.27,8H12.13C12.21,8.5 12.25,9 12.25,9.5C12.25,10 12.21,10.5 12.13,11H14.27M7.87,8L7.75,9.5L7.87,11H11.13C11.21,10.5 11.25,10 11.25,9.5C11.25,9 11.21,8.5 11.13,8H7.87M9.5,14.5C9.68,14.5 9.86,14.5 10.03,14.47C10.28,13.95 10.66,13.07 10.93,12H8.07C8.34,13.07 8.72,13.95 8.97,14.47L9.5,14.5M13.83,12H11.93C11.7,12.92 11.39,13.69 11.15,14.22C12.29,13.83 13.24,13.03 13.83,12M5.17,12C5.76,13.03 6.71,13.83 7.85,14.22C7.61,13.69 7.3,12.92 7.07,12H5.17Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Import Recipes | ||||
|       </h2> | ||||
|       <p> | ||||
|         Quickly and easily import recipes from sites around the web using the | ||||
|         built in <b>recipe scraper</b>. | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M12,3A9,9 0 0,0 3,12H0L4,16L8,12H5A7,7 0 0,1 12,5A7,7 0 0,1 19,12A7,7 0 0,1 12,19C10.5,19 9.09,18.5 7.94,17.7L6.5,19.14C8.04,20.3 9.94,21 12,21A9,9 0 0,0 21,12A9,9 0 0,0 12,3M14,12A2,2 0 0,0 12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Automatic Backups | ||||
|       </h2> | ||||
|       <p> | ||||
|         Keep your data safe with automatic backups in any format supported by | ||||
|         <b>Jinja2</b> templates | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M19,4C20.11,4 21,4.9 21,6V18A2,2 0 0,1 19,20H5C3.89,20 3,19.1 3,18V6A2,2 0 0,1 5,4H19M19,18V8H5V18H19Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Rich User Interface | ||||
|       </h2> | ||||
|       <p> | ||||
|         Use a beautiful and intuitive user interface to create, edit, and delete | ||||
|         recipes. Recipe editor supports <b>markdown syntax</b> | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M9,10V12H7V10H9M13,10V12H11V10H13M17,10V12H15V10H17M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5A2,2 0 0,1 5,3H6V1H8V3H16V1H18V3H19M19,19V8H5V19H19M9,14V16H7V14H9M13,14V16H11V14H13M17,14V16H15V14H17Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Meal Planner | ||||
|       </h2> | ||||
|       <p>Create Meal Plans for the week, month, or year!</p> | ||||
|     </div> | ||||
|   </div> | ||||
|   <!-- Row 2 --> | ||||
|  | ||||
| <div class="top-hr"> | ||||
|   <div class="top-hr"> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,4.5L8.95,4.53C8.71,5.05 8.34,5.93 8.07,7H10.93C10.66,5.93 10.29,5.05 10.05,4.53C9.87,4.5 9.69,4.5 9.5,4.5M13.83,7C13.24,5.97 12.29,5.17 11.15,4.78C11.39,5.31 11.7,6.08 11.93,7H13.83M5.17,7H7.07C7.3,6.08 7.61,5.31 7.85,4.78C6.71,5.17 5.76,5.97 5.17,7M4.5,9.5C4.5,10 4.58,10.53 4.73,11H6.87L6.75,9.5L6.87,8H4.73C4.58,8.47 4.5,9 4.5,9.5M14.27,11C14.42,10.53 14.5,10 14.5,9.5C14.5,9 14.42,8.47 14.27,8H12.13C12.21,8.5 12.25,9 12.25,9.5C12.25,10 12.21,10.5 12.13,11H14.27M7.87,8L7.75,9.5L7.87,11H11.13C11.21,10.5 11.25,10 11.25,9.5C11.25,9 11.21,8.5 11.13,8H7.87M9.5,14.5C9.68,14.5 9.86,14.5 10.03,14.47C10.28,13.95 10.66,13.07 10.93,12H8.07C8.34,13.07 8.72,13.95 8.97,14.47L9.5,14.5M13.83,12H11.93C11.7,12.92 11.39,13.69 11.15,14.22C12.29,13.83 13.24,13.03 13.83,12M5.17,12C5.76,13.03 6.71,13.83 7.85,14.22C7.61,13.69 7.3,12.92 7.07,12H5.17Z" /> | ||||
|             </svg> | ||||
|             Import Recipes | ||||
|         </h2> | ||||
|         <p> | ||||
|             Quickly and easily import recipes from sites around the web using the built in <b>recipe scrapper</b>. | ||||
|         </p> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Users | ||||
|       </h2> | ||||
|       <p> | ||||
|         Add new users with sign-up links or simply create a new user in the | ||||
|         admin panel. | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M12,3A9,9 0 0,0 3,12H0L4,16L8,12H5A7,7 0 0,1 12,5A7,7 0 0,1 19,12A7,7 0 0,1 12,19C10.5,19 9.09,18.5 7.94,17.7L6.5,19.14C8.04,20.3 9.94,21 12,21A9,9 0 0,0 21,12A9,9 0 0,0 12,3M14,12A2,2 0 0,0 12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12Z" /> | ||||
|             </svg> | ||||
|             Automatic Backups | ||||
|         </h2> | ||||
|         <p> | ||||
|             Keep your data safe with automatic backups in any format supported by <b>Jinja2</b> templates | ||||
|         </p> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Groups | ||||
|       </h2> | ||||
|       <p> | ||||
|         Sort users into groups to share recipes with the whole family, but keep | ||||
|         your Meal Plans separate. | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M19,4C20.11,4 21,4.9 21,6V18A2,2 0 0,1 19,20H5C3.89,20 3,19.1 3,18V6A2,2 0 0,1 5,4H19M19,18V8H5V18H19Z" /> | ||||
|             </svg> | ||||
|             Rich User Interface | ||||
|         </h2> | ||||
|         <p> Use a beautiful and intuitive user interface to create, edit, and delete recipes. Recipe editor supports <b>markdown syntax</b> </p> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M10.46,19C9,21.07 6.15,21.59 4.09,20.15C2.04,18.71 1.56,15.84 3,13.75C3.87,12.5 5.21,11.83 6.58,11.77L6.63,13.2C5.72,13.27 4.84,13.74 4.27,14.56C3.27,16 3.58,17.94 4.95,18.91C6.33,19.87 8.26,19.5 9.26,18.07C9.57,17.62 9.75,17.13 9.82,16.63V15.62L15.4,15.58L15.47,15.47C16,14.55 17.15,14.23 18.05,14.75C18.95,15.27 19.26,16.43 18.73,17.35C18.2,18.26 17.04,18.58 16.14,18.06C15.73,17.83 15.44,17.46 15.31,17.04L11.24,17.06C11.13,17.73 10.87,18.38 10.46,19M17.74,11.86C20.27,12.17 22.07,14.44 21.76,16.93C21.45,19.43 19.15,21.2 16.62,20.89C15.13,20.71 13.9,19.86 13.19,18.68L14.43,17.96C14.92,18.73 15.75,19.28 16.75,19.41C18.5,19.62 20.05,18.43 20.26,16.76C20.47,15.09 19.23,13.56 17.5,13.35C16.96,13.29 16.44,13.36 15.97,13.53L15.12,13.97L12.54,9.2H12.32C11.26,9.16 10.44,8.29 10.47,7.25C10.5,6.21 11.4,5.4 12.45,5.44C13.5,5.5 14.33,6.35 14.3,7.39C14.28,7.83 14.11,8.23 13.84,8.54L15.74,12.05C16.36,11.85 17.04,11.78 17.74,11.86M8.25,9.14C7.25,6.79 8.31,4.1 10.62,3.12C12.94,2.14 15.62,3.25 16.62,5.6C17.21,6.97 17.09,8.47 16.42,9.67L15.18,8.95C15.6,8.14 15.67,7.15 15.27,6.22C14.59,4.62 12.78,3.85 11.23,4.5C9.67,5.16 8.97,7 9.65,8.6C9.93,9.26 10.4,9.77 10.97,10.11L11.36,10.32L8.29,15.31C8.32,15.36 8.36,15.42 8.39,15.5C8.88,16.41 8.54,17.56 7.62,18.05C6.71,18.54 5.56,18.18 5.06,17.24C4.57,16.31 4.91,15.16 5.83,14.67C6.22,14.46 6.65,14.41 7.06,14.5L9.37,10.73C8.9,10.3 8.5,9.76 8.25,9.14Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Webhooks | ||||
|       </h2> | ||||
|       <p> | ||||
|         Schedule webhooks to send notifications to 3rd party services with | ||||
|         todays Meal Plan data. | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M9,10V12H7V10H9M13,10V12H11V10H13M17,10V12H15V10H17M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5A2,2 0 0,1 5,3H6V1H8V3H16V1H18V3H19M19,19V8H5V19H19M9,14V16H7V14H9M13,14V16H11V14H13M17,14V16H15V14H17Z" /> | ||||
|             </svg> | ||||
|             Meal Planner | ||||
|         </h2> | ||||
|         <p>Create Meal Plans for the week, month, or year! </p> | ||||
|       <h2> | ||||
|         <svg style="width: 24px; height: 24px" viewBox="0 0 24 24"> | ||||
|           <path | ||||
|             fill="currentColor" | ||||
|             d="M7 7H5A2 2 0 0 0 3 9V17H5V13H7V17H9V9A2 2 0 0 0 7 7M7 11H5V9H7M14 7H10V17H12V13H14A2 2 0 0 0 16 11V9A2 2 0 0 0 14 7M14 11H12V9H14M20 9V15H21V17H17V15H18V9H17V7H21V9Z" | ||||
|           /> | ||||
|         </svg> | ||||
|         Open API | ||||
|       </h2> | ||||
|       <p> | ||||
|         <b>API Driven</b> application gives you full control of the backend | ||||
|         server with interactive documentation | ||||
|       </p> | ||||
|     </div> | ||||
| </div> | ||||
| <!-- Row 2 --> | ||||
|  | ||||
| <div class="top-hr"> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" /> | ||||
|             </svg> | ||||
|             Users | ||||
|         </h2> | ||||
|         <p> | ||||
|             Add new users with sign-up links or simply create a new user in the admin panel.  | ||||
|         </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" /> | ||||
|             </svg> | ||||
|             Groups | ||||
|         </h2> | ||||
|         <p> | ||||
|             Sort users into groups to share recipes with the whole family, but keep your Meal Plans separate. | ||||
|         </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M10.46,19C9,21.07 6.15,21.59 4.09,20.15C2.04,18.71 1.56,15.84 3,13.75C3.87,12.5 5.21,11.83 6.58,11.77L6.63,13.2C5.72,13.27 4.84,13.74 4.27,14.56C3.27,16 3.58,17.94 4.95,18.91C6.33,19.87 8.26,19.5 9.26,18.07C9.57,17.62 9.75,17.13 9.82,16.63V15.62L15.4,15.58L15.47,15.47C16,14.55 17.15,14.23 18.05,14.75C18.95,15.27 19.26,16.43 18.73,17.35C18.2,18.26 17.04,18.58 16.14,18.06C15.73,17.83 15.44,17.46 15.31,17.04L11.24,17.06C11.13,17.73 10.87,18.38 10.46,19M17.74,11.86C20.27,12.17 22.07,14.44 21.76,16.93C21.45,19.43 19.15,21.2 16.62,20.89C15.13,20.71 13.9,19.86 13.19,18.68L14.43,17.96C14.92,18.73 15.75,19.28 16.75,19.41C18.5,19.62 20.05,18.43 20.26,16.76C20.47,15.09 19.23,13.56 17.5,13.35C16.96,13.29 16.44,13.36 15.97,13.53L15.12,13.97L12.54,9.2H12.32C11.26,9.16 10.44,8.29 10.47,7.25C10.5,6.21 11.4,5.4 12.45,5.44C13.5,5.5 14.33,6.35 14.3,7.39C14.28,7.83 14.11,8.23 13.84,8.54L15.74,12.05C16.36,11.85 17.04,11.78 17.74,11.86M8.25,9.14C7.25,6.79 8.31,4.1 10.62,3.12C12.94,2.14 15.62,3.25 16.62,5.6C17.21,6.97 17.09,8.47 16.42,9.67L15.18,8.95C15.6,8.14 15.67,7.15 15.27,6.22C14.59,4.62 12.78,3.85 11.23,4.5C9.67,5.16 8.97,7 9.65,8.6C9.93,9.26 10.4,9.77 10.97,10.11L11.36,10.32L8.29,15.31C8.32,15.36 8.36,15.42 8.39,15.5C8.88,16.41 8.54,17.56 7.62,18.05C6.71,18.54 5.56,18.18 5.06,17.24C4.57,16.31 4.91,15.16 5.83,14.67C6.22,14.46 6.65,14.41 7.06,14.5L9.37,10.73C8.9,10.3 8.5,9.76 8.25,9.14Z" /> | ||||
|             </svg> | ||||
|             Webhooks | ||||
|         </h2> | ||||
|         <p> Schedule webhooks to send notifications to 3rd party services with todays Meal Plan data. </p> | ||||
|     </div> | ||||
|     <div class="feature-item"> | ||||
|         <h2> | ||||
|             <svg style="width:24px;height:24px" viewBox="0 0 24 24"> | ||||
|                 <path fill="currentColor" d="M7 7H5A2 2 0 0 0 3 9V17H5V13H7V17H9V9A2 2 0 0 0 7 7M7 11H5V9H7M14 7H10V17H12V13H14A2 2 0 0 0 16 11V9A2 2 0 0 0 14 7M14 11H12V9H14M20 9V15H21V17H17V15H18V9H17V7H21V9Z" /> | ||||
|             </svg> | ||||
|             Open API | ||||
|         </h2> | ||||
|         <p> <b>API Driven</b> application gives you full control of the backend server with interactive documentation</p> | ||||
|     </div> | ||||
| </div> | ||||
|   </div> | ||||
| </section> | ||||
|  | ||||
|  | ||||
| <!-- Custom narrow footer --> | ||||
| <div class="md-footer-meta__inner md-grid"> | ||||
|     <div class="md-footer-social"> | ||||
|     <a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank" title="github.com"> | ||||
|         <svg viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg"><path d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z"></path></svg> | ||||
|   <div class="md-footer-social"> | ||||
|     <a | ||||
|       class="md-footer-social__link" | ||||
|       href="https://github.com/hay-kot/mealie" | ||||
|       rel="noopener" | ||||
|       target="_blank" | ||||
|       title="github.com" | ||||
|     > | ||||
|       <svg viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg"> | ||||
|         <path | ||||
|           d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z" | ||||
|         ></path> | ||||
|       </svg> | ||||
|     </a> | ||||
|     <a class="md-footer-social__link" href="https://twitter.com/kot_hay" rel="noopener" target="_blank" title="twitter.com"> | ||||
|         <svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path></svg> | ||||
|     <a | ||||
|       class="md-footer-social__link" | ||||
|       href="https://twitter.com/kot_hay" | ||||
|       rel="noopener" | ||||
|       target="_blank" | ||||
|       title="twitter.com" | ||||
|     > | ||||
|       <svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"> | ||||
|         <path | ||||
|           d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z" | ||||
|         ></path> | ||||
|       </svg> | ||||
|     </a> | ||||
|     <a class="md-footer-social__link" href="https://www.linkedin.com/in/hay-kot" rel="noopener" target="_blank" title="www.linkedin.com"> | ||||
|         <svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg"><path d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"></path></svg> | ||||
|     <a | ||||
|       class="md-footer-social__link" | ||||
|       href="https://www.linkedin.com/in/hay-kot" | ||||
|       rel="noopener" | ||||
|       target="_blank" | ||||
|       title="www.linkedin.com" | ||||
|     > | ||||
|       <svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg"> | ||||
|         <path | ||||
|           d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z" | ||||
|         ></path> | ||||
|       </svg> | ||||
|     </a> | ||||
|  | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| {% block content %}{% endblock %} | ||||
| {% block footer %}{% endblock %} | ||||
| {% endblock %} {% block content %}{% endblock %} {% block footer %}{% endblock | ||||
| %} | ||||
|   | ||||
| @@ -21,7 +21,13 @@ | ||||
|         <v-card-title> </v-card-title> | ||||
|         <v-form @submit.prevent="select"> | ||||
|           <v-card-text> | ||||
|             <v-text-field v-model="itemName" dense :label="inputLabel" :rules="[rules.required]"></v-text-field> | ||||
|             <v-text-field | ||||
|               v-model="itemName" | ||||
|               dense | ||||
|               :label="inputLabel" | ||||
|               :rules="[rules.required]" | ||||
|               autofocus | ||||
|             ></v-text-field> | ||||
|           </v-card-text> | ||||
|           <v-card-actions> | ||||
|             <BaseButton cancel @click="dialog = false" /> | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import secrets | ||||
| from datetime import datetime, timedelta | ||||
| from pathlib import Path | ||||
| @@ -6,16 +8,17 @@ from jose import jwt | ||||
| from passlib.context import CryptContext | ||||
|  | ||||
| from mealie.core.config import get_app_settings | ||||
| from mealie.db.data_access_layer.access_model_factory import Database | ||||
| from mealie.db.database import get_database | ||||
| from mealie.schema.user import PrivateUser | ||||
|  | ||||
| settings = get_app_settings() | ||||
|  | ||||
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | ||||
| ALGORITHM = "HS256" | ||||
|  | ||||
|  | ||||
| def create_access_token(data: dict(), expires_delta: timedelta = None) -> str: | ||||
|     settings = get_app_settings() | ||||
|  | ||||
|     to_encode = data.copy() | ||||
|     expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME) | ||||
|  | ||||
| @@ -35,18 +38,61 @@ def create_recipe_slug_token(file_path: str) -> str: | ||||
|     return create_access_token(token_data, expires_delta=timedelta(minutes=30)) | ||||
|  | ||||
|  | ||||
| def authenticate_user(session, email: str, password: str) -> PrivateUser: | ||||
|     db = get_database(session) | ||||
| def user_from_ldap(db: Database, session, username: str, password: str) -> PrivateUser: | ||||
|     """Given a username and password, tries to authenticate by BINDing to an | ||||
|     LDAP server | ||||
|  | ||||
|     If the BIND succeeds, it will either create a new user of that username on | ||||
|     the server or return an existing one. | ||||
|     Returns False on failure. | ||||
|     """ | ||||
|     import ldap | ||||
|  | ||||
|     settings = get_app_settings() | ||||
|  | ||||
|     conn = ldap.initialize(settings.LDAP_SERVER_URL) | ||||
|     user_dn = settings.LDAP_BIND_TEMPLATE.format(username) | ||||
|     try: | ||||
|         conn.simple_bind_s(user_dn, password) | ||||
|     except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT): | ||||
|         return False | ||||
|  | ||||
|     user = db.users.get_one(username, "username", any_case=True) | ||||
|     if not user: | ||||
|         user = db.users.create( | ||||
|             { | ||||
|                 "username": username, | ||||
|                 "password": "LDAP", | ||||
|                 # Fill the next two values with something unique and vaguely | ||||
|                 # relevant | ||||
|                 "full_name": username, | ||||
|                 "email": username, | ||||
|                 "admin": False, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     if settings.LDAP_ADMIN_FILTER: | ||||
|         user.admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0 | ||||
|         db.users.update(user.id, user) | ||||
|  | ||||
|     return user | ||||
|  | ||||
|  | ||||
| def authenticate_user(session, email: str, password: str) -> PrivateUser | False: | ||||
|     settings = get_app_settings() | ||||
|  | ||||
|     db = get_database(session) | ||||
|     user: PrivateUser = db.users.get(email, "email", any_case=True) | ||||
|  | ||||
|     if not user: | ||||
|         user = db.users.get(email, "username", any_case=True) | ||||
|     if not user: | ||||
|  | ||||
|     if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"): | ||||
|         return user_from_ldap(db, session, email, password) | ||||
|  | ||||
|     if not user or not verify_password(password, user.password): | ||||
|         return False | ||||
|  | ||||
|     if not verify_password(password, user.password): | ||||
|         return False | ||||
|     return user | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -83,6 +83,25 @@ class AppSettings(BaseSettings): | ||||
|  | ||||
|         return "" not in required and None not in required | ||||
|  | ||||
|     # =============================================== | ||||
|     # LDAP Configuration | ||||
|  | ||||
|     LDAP_AUTH_ENABLED: bool = False | ||||
|     LDAP_SERVER_URL: str = None | ||||
|     LDAP_BIND_TEMPLATE: str = None | ||||
|     LDAP_ADMIN_FILTER: str = None | ||||
|  | ||||
|     @property | ||||
|     def LDAP_ENABLED(self) -> bool: | ||||
|         """Validates LDAP settings are all set""" | ||||
|         required = { | ||||
|             self.LDAP_SERVER_URL, | ||||
|             self.LDAP_BIND_TEMPLATE, | ||||
|             self.LDAP_ADMIN_FILTER, | ||||
|         } | ||||
|  | ||||
|         return "" not in required and None not in required and self.LDAP_AUTH_ENABLED | ||||
|  | ||||
|     class Config: | ||||
|         arbitrary_types_allowed = True | ||||
|  | ||||
|   | ||||
| @@ -43,6 +43,8 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: | ||||
|  | ||||
| def scrape_image(image_url: str, slug: str) -> Path: | ||||
|     logger.info(f"Image URL: {image_url}") | ||||
|     _FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" | ||||
|  | ||||
|     if isinstance(image_url, str):  # Handles String Types | ||||
|         pass | ||||
|  | ||||
| @@ -54,7 +56,7 @@ def scrape_image(image_url: str, slug: str) -> Path: | ||||
|         all_image_requests = [] | ||||
|         for url in image_url: | ||||
|             try: | ||||
|                 r = requests.get(url, stream=True, headers={"User-Agent": ""}) | ||||
|                 r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA}) | ||||
|             except Exception: | ||||
|                 logger.exception("Image {url} could not be requested") | ||||
|                 continue | ||||
| @@ -72,7 +74,7 @@ def scrape_image(image_url: str, slug: str) -> Path: | ||||
|     filename = Recipe(slug=slug).image_dir.joinpath(filename) | ||||
|  | ||||
|     try: | ||||
|         r = requests.get(image_url, stream=True) | ||||
|         r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA}) | ||||
|     except Exception: | ||||
|         logger.exception("Fatal Image Request Exception") | ||||
|         return None | ||||
|   | ||||
							
								
								
									
										43
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										43
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -813,6 +813,17 @@ category = "main" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
|  | ||||
| [[package]] | ||||
| name = "pyasn1-modules" | ||||
| version = "0.2.8" | ||||
| description = "A collection of ASN.1-based protocols modules." | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
|  | ||||
| [package.dependencies] | ||||
| pyasn1 = ">=0.4.6,<0.5.0" | ||||
|  | ||||
| [[package]] | ||||
| name = "pycodestyle" | ||||
| version = "2.7.0" | ||||
| @@ -1016,6 +1027,18 @@ cryptography = ["cryptography (>=3.4.0)"] | ||||
| pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"] | ||||
| pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"] | ||||
|  | ||||
| [[package]] | ||||
| name = "python-ldap" | ||||
| version = "3.3.1" | ||||
| description = "Python modules for implementing LDAP clients" | ||||
| category = "main" | ||||
| optional = false | ||||
| python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" | ||||
|  | ||||
| [package.dependencies] | ||||
| pyasn1 = ">=0.3.7" | ||||
| pyasn1_modules = ">=0.1.5" | ||||
|  | ||||
| [[package]] | ||||
| name = "python-multipart" | ||||
| version = "0.0.5" | ||||
| @@ -1423,7 +1446,7 @@ pgsql = ["psycopg2-binary"] | ||||
| [metadata] | ||||
| lock-version = "1.1" | ||||
| python-versions = "^3.9" | ||||
| content-hash = "597bcfac6b50f5f6e203db40e05546b1a9aaf4c8438790d233424cf66fc84d19" | ||||
| content-hash = "cd88ddf0b5bd0a771a2931c82acc8923fab2a743269e7ac0ae323eab9f1b38d5" | ||||
|  | ||||
| [metadata.files] | ||||
| aiofiles = [ | ||||
| @@ -2079,6 +2102,21 @@ pyasn1 = [ | ||||
|     {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, | ||||
|     {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, | ||||
| ] | ||||
| pyasn1-modules = [ | ||||
|     {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, | ||||
|     {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, | ||||
| ] | ||||
| pycodestyle = [ | ||||
|     {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, | ||||
|     {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, | ||||
| @@ -2168,6 +2206,9 @@ python-jose = [ | ||||
|     {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, | ||||
|     {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, | ||||
| ] | ||||
| python-ldap = [ | ||||
|     {file = "python-ldap-3.3.1.tar.gz", hash = "sha256:4711cacf013e298754abd70058ccc995758177fb425f1c2d30e71adfc1d00aa5"}, | ||||
| ] | ||||
| python-multipart = [ | ||||
|     {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, | ||||
| ] | ||||
|   | ||||
| @@ -37,6 +37,7 @@ psycopg2-binary = {version = "^2.9.1", optional = true} | ||||
| gunicorn = "^20.1.0" | ||||
| emails = "^0.6" | ||||
| python-i18n = "^0.3.9" | ||||
| python-ldap = "^3.3.1" | ||||
|  | ||||
| [tool.poetry.dev-dependencies] | ||||
| pylint = "^2.6.0" | ||||
|   | ||||
| @@ -34,3 +34,9 @@ LANG=en-US | ||||
| # SMTP_USER="" | ||||
| # SMTP_PASSWORD="" | ||||
|  | ||||
| # Configuration for authentication via an external LDAP server | ||||
| LDAP_AUTH_ENABLED=False | ||||
| LDAP_SERVER_URL=None | ||||
| LDAP_BIND_TEMPLATE=None | ||||
| LDAP_ADMIN_FILTER=None | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,12 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| from pytest import MonkeyPatch | ||||
|  | ||||
| from mealie.core import security | ||||
| from mealie.core.config import get_app_settings | ||||
| from mealie.core.dependencies import validate_file_token | ||||
| from mealie.db.db_setup import create_session | ||||
| from tests.utils.factories import random_string | ||||
|  | ||||
|  | ||||
| def test_create_file_token(): | ||||
| @@ -9,3 +14,39 @@ def test_create_file_token(): | ||||
|     file_token = security.create_file_token(file_path) | ||||
|  | ||||
|     assert file_path == validate_file_token(file_token) | ||||
|  | ||||
|  | ||||
| def test_ldap_authentication_mocked(monkeypatch: MonkeyPatch): | ||||
|     import ldap | ||||
|  | ||||
|     user = random_string(10) | ||||
|     password = random_string(10) | ||||
|     bind_template = "cn={},dc=example,dc=com" | ||||
|     admin_filter = "(memberOf=cn=admins,dc=example,dc=com)" | ||||
|     monkeypatch.setenv("LDAP_AUTH_ENABLED", "true") | ||||
|     monkeypatch.setenv("LDAP_SERVER_URL", "")  # Not needed due to mocking | ||||
|     monkeypatch.setenv("LDAP_BIND_TEMPLATE", bind_template) | ||||
|     monkeypatch.setenv("LDAP_ADMIN_FILTER", admin_filter) | ||||
|  | ||||
|     class LdapConnMock: | ||||
|         def simple_bind_s(self, dn, bind_pw): | ||||
|             assert dn == bind_template.format(user) | ||||
|             return bind_pw == password | ||||
|  | ||||
|         def search_s(self, dn, scope, filter, attrlist): | ||||
|             assert attrlist == [] | ||||
|             assert filter == admin_filter | ||||
|             assert dn == bind_template.format(user) | ||||
|             assert scope == ldap.SCOPE_BASE | ||||
|             return [()] | ||||
|  | ||||
|     def ldap_initialize_mock(url): | ||||
|         assert url == "" | ||||
|         return LdapConnMock() | ||||
|  | ||||
|     monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock) | ||||
|  | ||||
|     get_app_settings.cache_clear() | ||||
|     result = security.authenticate_user(create_session(), user, password) | ||||
|     assert result is not False | ||||
|     assert result.username == user | ||||
|   | ||||
		Reference in New Issue
	
	Block a user