Merge remote-tracking branch 'origin/next' into feature/vultr-cloud-provider
This commit is contained in:
commit
d74206f4a9
43 changed files with 1145 additions and 220 deletions
173
README.md
173
README.md
|
|
@ -57,106 +57,95 @@ ## Donations
|
|||
|
||||
### Huge Sponsors
|
||||
|
||||
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
||||
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
|
||||
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
|
||||
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
|
||||
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
|
||||
* [Context.dev](https://www.context.dev/) - Web scraping API for AI agents
|
||||
* [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API.
|
||||
* [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality
|
||||
* [ScreenshotOne](https://screenshotone.com) - Screenshot API for devs
|
||||
* [PrivateAlps](https://privatealps.net) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
|
||||
* [Seibert Group](https://seibert.link/coolifysoftware) - Boost productivity company-wide with AI agents like Claude Code
|
||||
* [Contabo](https://contabo.com/en/coolify-vps/) - Cloud VPS & dedicated servers at unbeatable prices
|
||||
|
||||
### Big Sponsors
|
||||
|
||||
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
|
||||
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
|
||||
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
|
||||
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
|
||||
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
|
||||
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
|
||||
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
|
||||
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
|
||||
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
|
||||
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
|
||||
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
|
||||
* [Cloudways](https://www.cloudways.com/en/?id=2125302) - Managed cloud hosting platform by DigitalOcean
|
||||
* [ByteBase](https://www.bytebase.com) - Database CI/CD and Security at Scale
|
||||
* [Ramnode](https://ramnode.com/) - High Performance Cloud VPS Hosting
|
||||
* [23M](https://23m.com) - Your experts for high-availability hosting solutions!
|
||||
* [Macarne](https://macarne.com) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
|
||||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
|
||||
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
|
||||
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
|
||||
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
|
||||
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
|
||||
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
|
||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||
* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions
|
||||
|
||||
* [Logto](https://logto.io) - The better identity infrastructure for developers
|
||||
* [Supadata](https://supadata.ai/) - Scrape YouTube, web, and files. Get AI-ready, clean data for your next project.
|
||||
* [Tolgee](https://tolgee.io) - The open source localization platform
|
||||
* [Best Consultant](https://bc.direct) - Your trusted technology consulting partner
|
||||
* [ArcJet](https://arcjet.com) - Advanced web security and performance solutions
|
||||
* [SupaGuide](https://supa.guide) - Your comprehensive guide to Supabase
|
||||
* [CodeRabbit](https://coderabbit.ai) - Cut Code Review Time & Bugs in Half
|
||||
* [Convex](https://convex.link/coolify.io) - Convex is the open-source reactive database for web app developers.
|
||||
* [GoldenVM](https://billing.goldenvm.com) - Premium virtual machine hosting solutions
|
||||
* [Comit International](https://comit.international) - New York Times award–winning contractor!
|
||||
* [Compai](https://www.trycomp.ai) - The open source compliance automation platform that does everything you need to get compliant, fast. Open source alternative to Drata & Vanta.
|
||||
* [Tigris](https://www.tigrisdata.com) - Modern S3 Alternative
|
||||
* [Blacksmith](https://blacksmith.sh) - Infrastructure automation platform
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs) - 30,000+ remote jobs for developers
|
||||
* [Darweb](https://darweb.nl/?ref=coolify.io&utm_source=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions for eCommerce.
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting) - Web hosting and VPS solutions
|
||||
* [Mobb](https://vibe.mobb.ai/) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [Ubicloud](https://www.ubicloud.com) - Open source cloud infrastructure platform
|
||||
* [PFGLabs](https://pfglabs.com) - Build Real Projects with Golang
|
||||
* [JuxtDigital](https://juxtdigital.com) - Digital PR & AI Authority Building Agency
|
||||
* [SaasyKit](https://saasykit.com) - Complete SaaS starter kit for developers
|
||||
* [American Cloud](https://americancloud.com) - US-based cloud infrastructure services
|
||||
* [LiquidWeb](https://liquidweb.com) - Premium managed hosting solutions
|
||||
* [Greptile](https://www.greptile.com) - The AI Code Reviewer
|
||||
* [VPSDime](https://vpsdime.com/) - Cheap VPS Hosting - 4GB for $5/month
|
||||
* [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
|
||||
* [ISHosting](https://ishosting.com/) - Hosting and VPS solutions
|
||||
* [PetroSky Cloud](https://petrosky.io) - Open source cloud deployment solutions
|
||||
* [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting
|
||||
|
||||
### Small Sponsors
|
||||
|
||||
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
|
||||
<a href="https://bsky.app/profile/jyc.dev"><img width="60px" alt="jyc.dev" src="https://github.com/jycouet.png"/></a>
|
||||
<a href="https://github.com/therealjp?utm_source=coolify.io"><img width="60px" alt="TheRealJP" src="https://github.com/therealjp.png"/></a>
|
||||
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
|
||||
<a href="https://github.com/aniftyco"><img width="60px" alt="NiftyCo" src="https://github.com/aniftyco.png"/></a>
|
||||
<a href="https://dry.software/?utm_source=coolify.io"><img width="60px" alt="Dry Software" src="https://github.com/dry-software.png"/></a>
|
||||
<a href="https://lightspeed.run/?utm_source=coolify.io"><img width="60px" alt="Lightspeed.run" src="https://github.com/lightspeedrun.png"/></a>
|
||||
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://github.com/LLM-Inc.png"/></a>
|
||||
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
|
||||
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img width="60px" alt="BitLaunch" src="https://github.com/bitlaunchio.png"/></a>
|
||||
<a href="https://bestforandroid.com/?utm_source=coolify.io"><img width="60px" alt="Best for Android" src="https://github.com/bestforandroid.png"/></a>
|
||||
<a href="https://il.ly/?utm_source=coolify.io"><img width="60px" alt="Ilias Ism" src="https://github.com/Illyism.png"/></a>
|
||||
<a href="https://formbricks.com/?utm_source=coolify.io"><img width="60px" alt="Formbricks" src="https://github.com/formbricks.png"/></a>
|
||||
<a href="https://www.serversearcher.com/"><img width="60px" alt="Server Searcher" src="https://github.com/serversearcher.png"/></a>
|
||||
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://coolify.io/images/reshotai.png"/></a>
|
||||
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://coolify.io/images/cirun-logo.png"/></a>
|
||||
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="Typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
|
||||
<a href="https://cccareers.org/?utm_source=coolify.io"><img width="60px" alt="Creating Coding Careers" src="https://github.com/cccareers.png"/></a>
|
||||
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://coolify.io/images/internetgarden.ico"/></a>
|
||||
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://coolify.io/images/web3jobs.png"/></a>
|
||||
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://coolify.io/images/codext.jpg"/></a>
|
||||
<a href="https://github.com/monocursive"><img width="60px" alt="Michael Mazurczak" src="https://github.com/monocursive.png"/></a>
|
||||
<a href="https://fider.io/?utm_source=coolify.io"><img width="60px" alt="Fider" src="https://github.com/getfider.png"/></a>
|
||||
<a href="https://www.flint.sh/en/home?utm_source=coolify.io"><img width="60px" alt="Flint" src="https://github.com/Flint-company.png"/></a>
|
||||
<a href="https://github.com/urtho"><img width="60px" alt="Paweł Pierścionek" src="https://github.com/urtho.png"/></a>
|
||||
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
|
||||
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
|
||||
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
|
||||
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
|
||||
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
|
||||
<a href="https://startupfa.me?utm_source=coolify.io"><img width="60px" alt="Startup Fame" src="https://github.com/startupfame.png"/></a>
|
||||
<a href="https://barrad.me/?utm_source=coolify.io"><img width="60px" alt="Younes Barrad" src="https://github.com/Flowko.png"/></a>
|
||||
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img width="60px" alt="Jonas Jaeger" src="https://github.com/toxin20.png"/></a>
|
||||
<a href="https://pixel.ao/?utm_source=coolify.io"><img width="60px" alt="Pixel Infinito" src="https://github.com/pixelinfinito.png"/></a>
|
||||
<a href="https://github.com/corentinclichy"><img width="60px" alt="Corentin Clichy" src="https://github.com/corentinclichy.png"/></a>
|
||||
<a href="https://x.com/mrsmith9ja?utm_source=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
|
||||
<a href="https://devhuset.no?utm_source=coolify.io"><img width="60px" alt="Devhuset" src="https://github.com/devhuset.png"/></a>
|
||||
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://coolify.io/images/arvensis.png"/></a>
|
||||
<a href="https://github.com/Niki2k1"><img width="60px" alt="Niklas Lausch" src="https://github.com/Niki2k1.png"/></a>
|
||||
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
|
||||
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
|
||||
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
|
||||
<a href="https://www.movavi.com/mac.html?utm_source=coolify.io"><img width="60px" alt="Movavi" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/movavi.png"/></a>
|
||||
<a href="https://usefoil.com/"><img width="60px" alt="ABXY" src="https://usefoil.com/favicon.svg"/></a>
|
||||
<a href="https://www.launchfa.st/?utm_source=coolify.io"><img width="60px" alt="LaunchFast Boilerplates" src="https://github.com/LaunchFast-Boilerplates.png"/></a>
|
||||
<a href="https://vanaways.co.uk/?utm_source=coolify.io"><img width="60px" alt="Vanaways" src="https://github.com/Vanaways.png"/></a>
|
||||
<a href="https://www.netrouting.com/?utm_source=coolify.io"><img width="60px" alt="Netrouting" src="https://github.com/netroutingcom.png"/></a>
|
||||
<a href="https://github.com/mindedtech"><img width="60px" alt="MindEd Tech" src="https://github.com/mindedtech.png"/></a>
|
||||
<a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a>
|
||||
<a href="https://github.com/mindedtech?utm_source=coolify.io"><img width="60px" alt="MindedTech" src="https://github.com/mindedtech.png"/></a>
|
||||
<a href="https://netrouting.com/?utm_source=coolify.io"><img width="60px" alt="NetRouting" src="https://github.com/netroutingcom.png"/></a>
|
||||
<a href="https://github.com/parsecph?utm_source=coolify.io"><img width="60px" alt="ParsecPH" src="https://github.com/parsecph.png"/></a>
|
||||
|
||||
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
|
||||
<a href="https://www.autom.dev/?utm_source=coolify.io"><img width="60px" alt="Autom" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/autom.png"/></a>
|
||||
<a href="https://www.huntapi.com/?utm_source=coolify.io"><img width="60px" alt="HuntAPI" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/huntapi.png"/></a>
|
||||
<a href="https://ultraservers.com/?utm_source=coolify.io"><img width="60px" alt="ULTRASERVERS" src="https://github.com/ULTRASERVERS.png"/></a>
|
||||
<a href="https://vibetone.com/?utm_source=coolify.io"><img width="60px" alt="VibeTone" src="https://github.com/vibetonefm.png"/></a>
|
||||
<a href="https://www.piloterr.com/?utm_source=coolify.io"><img width="60px" alt="Piloterr" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/piloterr.svg"/></a>
|
||||
<a href="https://yoxel.com/?utm_source=coolify.io"><img width="60px" alt="Alexey Panteleev" src="https://github.com/aspantel.png"/></a>
|
||||
<a href="https://summyt.app?utm_source=coolify.io"><img width="60px" alt="SummYT - YouTube Summarizer" src="https://summyt.app/logo.svg"/></a>
|
||||
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="Xaman" src="https://github.com/XamanApp.png"/></a>
|
||||
<a href="https://monadical.com/?utm_source=coolify.io"><img width="60px" alt="Monadical" src="https://github.com/Monadical-SAS.png"/></a>
|
||||
<a href="https://maas.engineering/?utm_source=coolify.io"><img width="60px" alt="Magic as a Service" src="https://github.com/magicasaservice.png"/></a>
|
||||
<a href="https://fivemanage.com?utm_source=coolify.io"><img width="60px" alt="FiveManage" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/fivemanage.jpg"/></a>
|
||||
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
|
||||
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
|
||||
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
|
||||
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
|
||||
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/cirun-logo.png"/></a>
|
||||
<a href="https://github.com/puls-digital-group"><img width="60px" alt="Puls Digital Group" src="https://github.com/puls-digital-group.png"/></a>
|
||||
<a href="https://github.com/jonathanprl"><img width="60px" alt="Jonathan Pereira" src="https://github.com/jonathanprl.png"/></a>
|
||||
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/internetgarden.ico"/></a>
|
||||
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/web3jobs.png"/></a>
|
||||
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/linkdr.svg"/></a>
|
||||
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/arvensis.png"/></a>
|
||||
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/reshotai.png"/></a>
|
||||
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/runpod.svg"/></a>
|
||||
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
|
||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/codext.jpg"/></a>
|
||||
<a href="https://interviewpal.com"><img width="60px" alt="InterviewPal" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/interviewpal.svg"/></a>
|
||||
<a href="https://decidable.no?utm_source=coolify.io"><img width="60px" alt="Decidable" src="https://github.com/Decidable-AS.png"/></a>
|
||||
<a href="https://hosthavoc.com"><img width="60px" alt="Host Havoc" src="https://raw.githubusercontent.com/coollabsio/coollabs-cdn/main/images/sponsors/hosthavoc.png"/></a>
|
||||
|
||||
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
|
@ -12,6 +14,16 @@
|
|||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
private const REGISTRATION_IP_MAX_ATTEMPTS = 3;
|
||||
|
||||
private const REGISTRATION_IP_DECAY_SECONDS = 600;
|
||||
|
||||
private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3;
|
||||
|
||||
private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600;
|
||||
|
||||
public function __construct(private readonly Request $request) {}
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
|
|
@ -23,6 +35,9 @@ public function create(array $input): User
|
|||
if (! $settings->is_registration_enabled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->ensureRegistrationIsNotRateLimited($input);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
|
|
@ -72,4 +87,42 @@ public function create(array $input): User
|
|||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
private function ensureRegistrationIsNotRateLimited(array $input): void
|
||||
{
|
||||
$keys = [
|
||||
[
|
||||
'key' => 'registration:ip:'.sha1($this->realIp()),
|
||||
'max' => self::REGISTRATION_IP_MAX_ATTEMPTS,
|
||||
'decay' => self::REGISTRATION_IP_DECAY_SECONDS,
|
||||
],
|
||||
];
|
||||
|
||||
$emailIdentity = normalize_email_identity($input['email'] ?? null);
|
||||
if ($emailIdentity !== null) {
|
||||
$keys[] = [
|
||||
'key' => 'registration:email-identity:'.sha1($emailIdentity),
|
||||
'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS,
|
||||
'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($keys as $limit) {
|
||||
if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) {
|
||||
abort(429, 'Too many registration attempts. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($keys as $limit) {
|
||||
RateLimiter::hit($limit['key'], $limit['decay']);
|
||||
}
|
||||
}
|
||||
|
||||
private function realIp(): string
|
||||
{
|
||||
return $this->request->server('REMOTE_ADDR') ?? $this->request->ip();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ public function getListeners()
|
|||
return [
|
||||
'refreshServerShow' => 'refreshServer',
|
||||
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +204,15 @@ public function refreshServer()
|
|||
$this->server->load('settings');
|
||||
}
|
||||
|
||||
public function refreshSentinelStatus($event = null): void
|
||||
{
|
||||
if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Traefik has any outdated version info (patch or minor upgrade).
|
||||
* This shows a warning indicator in the navbar.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -82,7 +83,7 @@ public function boot(): void
|
|||
$user->save();
|
||||
|
||||
// Check if user has a pending invitation they haven't accepted yet
|
||||
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
|
||||
$invitation = TeamInvitation::whereEmail($email)->first();
|
||||
if ($invitation && $invitation->isValid()) {
|
||||
// User is logging in for the first time after being invited
|
||||
// Attach them to the invited team if not already attached
|
||||
|
|
@ -130,7 +131,16 @@ public function boot(): void
|
|||
// Use real client IP (not spoofable forwarded headers)
|
||||
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
|
||||
|
||||
return Limit::perMinute(5)->by($realIp);
|
||||
$limits = [
|
||||
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)),
|
||||
];
|
||||
|
||||
$emailIdentity = normalize_email_identity($request->input('email'));
|
||||
if ($emailIdentity !== null) {
|
||||
$limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity));
|
||||
}
|
||||
|
||||
return $limits;
|
||||
});
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
|
|
|
|||
20
bootstrap/helpers/email.php
Normal file
20
bootstrap/helpers/email.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function normalize_email_identity(?string $email): ?string
|
||||
{
|
||||
if (blank($email) || ! str_contains($email, '@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$localPart, $domain] = explode('@', Str::lower($email), 2);
|
||||
$localPart = Str::before($localPart, '+');
|
||||
$localPart = str_replace('.', '', $localPart);
|
||||
|
||||
if (blank($localPart) || blank($domain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $localPart.'@'.$domain;
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
<nav class="pt-2 pb-4 md:pb-10">
|
||||
<div class="flex min-w-0 flex-col gap-1 md:hidden">
|
||||
<div class="flex min-w-0 items-center text-xs text-neutral-400">
|
||||
<a class="min-w-0 truncate text-neutral-300 hover:text-warning" {{ wireNavigate() }}
|
||||
<a class="min-w-0 truncate" {{ wireNavigate() }}
|
||||
href="{{ $isApplication
|
||||
? route('project.application.configuration', $routeParams)
|
||||
: ($isService
|
||||
|
|
|
|||
|
|
@ -1,16 +1,139 @@
|
|||
<div class="sub-menu-wrapper">
|
||||
<a class="{{ request()->routeIs('server.proxy') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.proxy', $parameters) }}">
|
||||
<span class="menu-item-label">Configuration</span>
|
||||
</a>
|
||||
@if ($server->proxySet())
|
||||
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
|
||||
<span class="menu-item-label">Dynamic Configurations</span>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('server.proxy.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
|
||||
href="{{ route('server.proxy.logs', $parameters) }}">
|
||||
<span class="menu-item-label">Logs</span>
|
||||
</a>
|
||||
@endif
|
||||
@php
|
||||
$serverPageItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.show',
|
||||
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
|
||||
],
|
||||
[
|
||||
'label' => 'Proxy',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
|
||||
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
|
||||
],
|
||||
[
|
||||
'label' => 'Sentinel',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
||||
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'server.resources',
|
||||
'active' => request()->routeIs('server.resources'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
'visible' => auth()->user()?->can('update', $server),
|
||||
],
|
||||
];
|
||||
$proxyMenuItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy'),
|
||||
],
|
||||
[
|
||||
'label' => 'Dynamic Configurations',
|
||||
'route' => 'server.proxy.dynamic-confs',
|
||||
'active' => request()->routeIs('server.proxy.dynamic-confs'),
|
||||
'visible' => $server->proxySet(),
|
||||
],
|
||||
[
|
||||
'label' => 'Logs',
|
||||
'route' => 'server.proxy.logs',
|
||||
'active' => request()->routeIs('server.proxy.logs'),
|
||||
'visible' => $server->proxySet(),
|
||||
'navigate' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$serverPageItems = array_values(array_filter(
|
||||
$serverPageItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$proxyMenuItems = array_values(array_filter(
|
||||
$proxyMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$activeProxyMenuItem = collect($proxyMenuItems)->firstWhere('active', true) ?? $proxyMenuItems[0];
|
||||
$activeProxyMenuValue = (($activeProxyMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|proxy|'.route($activeProxyMenuItem['route'], $parameters);
|
||||
@endphp
|
||||
|
||||
<div class="w-full md:w-auto">
|
||||
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
|
||||
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||
<select id="server-mobile-section" class="select w-full" aria-label="Proxy menu"
|
||||
data-current-value="{{ $activeProxyMenuValue }}"
|
||||
x-data="{
|
||||
init() {
|
||||
this.syncFromLocation();
|
||||
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||
if (el.contains(this.$el)) {
|
||||
queueMicrotask(() => this.syncFromLocation());
|
||||
}
|
||||
});
|
||||
},
|
||||
selected: $el.dataset.currentValue,
|
||||
syncFromLocation() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const matchingOptions = Array.from(this.$el.options).filter((option) => {
|
||||
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||
|
||||
return optionUrl.pathname === currentUrl.pathname;
|
||||
});
|
||||
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|proxy|') || option.value.startsWith('location|proxy|')) || matchingOptions[0];
|
||||
|
||||
if (selectedOption) {
|
||||
this.selected = selectedOption.value;
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:livewire:navigated.window="syncFromLocation()"
|
||||
x-model="selected"
|
||||
x-on:change="
|
||||
const value = $event.target.value;
|
||||
const url = value.split('|').slice(2).join('|');
|
||||
|
||||
if (value.startsWith('navigate|')) {
|
||||
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
">
|
||||
<optgroup label="Server">
|
||||
@foreach ($serverPageItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="Proxy">
|
||||
@foreach ($proxyMenuItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|proxy|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
@foreach ($proxyMenuItems as $menuItem)
|
||||
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,127 @@
|
|||
<div class="sub-menu-wrapper">
|
||||
<a class="{{ request()->routeIs('server.security.patches') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.security.patches', $parameters) }}">
|
||||
<span class="menu-item-label">Server Patching</span>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('server.security.terminal-access') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
|
||||
href="{{ route('server.security.terminal-access', $parameters) }}">
|
||||
<span class="menu-item-label">Terminal Access</span>
|
||||
</a>
|
||||
@php
|
||||
$serverPageItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.show',
|
||||
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
|
||||
],
|
||||
[
|
||||
'label' => 'Proxy',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
|
||||
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
|
||||
],
|
||||
[
|
||||
'label' => 'Sentinel',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
||||
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'server.resources',
|
||||
'active' => request()->routeIs('server.resources'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
'visible' => auth()->user()?->can('update', $server),
|
||||
],
|
||||
];
|
||||
$securityMenuItems = [
|
||||
[
|
||||
'label' => 'Server Patching',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal Access',
|
||||
'route' => 'server.security.terminal-access',
|
||||
'active' => request()->routeIs('server.security.terminal-access'),
|
||||
'navigate' => false,
|
||||
],
|
||||
];
|
||||
$serverPageItems = array_values(array_filter(
|
||||
$serverPageItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$activeSecurityMenuItem = collect($securityMenuItems)->firstWhere('active', true) ?? $securityMenuItems[0];
|
||||
$activeSecurityMenuValue = (($activeSecurityMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|security|'.route($activeSecurityMenuItem['route'], $parameters);
|
||||
@endphp
|
||||
|
||||
<div class="w-full md:w-auto">
|
||||
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
|
||||
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||
<select id="server-mobile-section" class="select w-full" aria-label="Security menu"
|
||||
data-current-value="{{ $activeSecurityMenuValue }}"
|
||||
x-data="{
|
||||
init() {
|
||||
this.syncFromLocation();
|
||||
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||
if (el.contains(this.$el)) {
|
||||
queueMicrotask(() => this.syncFromLocation());
|
||||
}
|
||||
});
|
||||
},
|
||||
selected: $el.dataset.currentValue,
|
||||
syncFromLocation() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const matchingOptions = Array.from(this.$el.options).filter((option) => {
|
||||
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||
|
||||
return optionUrl.pathname === currentUrl.pathname;
|
||||
});
|
||||
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|security|') || option.value.startsWith('location|security|')) || matchingOptions[0];
|
||||
|
||||
if (selectedOption) {
|
||||
this.selected = selectedOption.value;
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:livewire:navigated.window="syncFromLocation()"
|
||||
x-model="selected"
|
||||
x-on:change="
|
||||
const value = $event.target.value;
|
||||
const url = value.split('|').slice(2).join('|');
|
||||
|
||||
if (value.startsWith('navigate|')) {
|
||||
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
">
|
||||
<optgroup label="Server">
|
||||
@foreach ($serverPageItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="Security">
|
||||
@foreach ($securityMenuItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|security|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
@foreach ($securityMenuItems as $menuItem)
|
||||
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,121 @@
|
|||
<div class="sub-menu-wrapper">
|
||||
@php
|
||||
$serverPageItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.show',
|
||||
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
|
||||
],
|
||||
[
|
||||
'label' => 'Proxy',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
|
||||
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
|
||||
],
|
||||
[
|
||||
'label' => 'Sentinel',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
||||
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'server.resources',
|
||||
'active' => request()->routeIs('server.resources'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
'visible' => auth()->user()?->can('update', $server),
|
||||
],
|
||||
];
|
||||
$sentinelMenuItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel'),
|
||||
],
|
||||
[
|
||||
'label' => 'Logs',
|
||||
'route' => 'server.sentinel.logs',
|
||||
'active' => request()->routeIs('server.sentinel.logs'),
|
||||
],
|
||||
];
|
||||
$serverPageItems = array_values(array_filter(
|
||||
$serverPageItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$activeSentinelMenuItem = collect($sentinelMenuItems)->firstWhere('active', true) ?? $sentinelMenuItems[0];
|
||||
$activeSentinelMenuValue = 'navigate|sentinel|'.route($activeSentinelMenuItem['route'], $parameters);
|
||||
@endphp
|
||||
|
||||
<div class="w-full md:w-auto">
|
||||
@can('viewSentinel', $server)
|
||||
<a class="{{ request()->routeIs('server.sentinel') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.sentinel', $parameters) }}">
|
||||
<span class="menu-item-label">Configuration</span>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('server.sentinel.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.sentinel.logs', $parameters) }}">
|
||||
<span class="menu-item-label">Logs</span>
|
||||
</a>
|
||||
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
|
||||
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||
<select id="server-mobile-section" class="select w-full" aria-label="Sentinel menu"
|
||||
data-current-value="{{ $activeSentinelMenuValue }}"
|
||||
x-data="{
|
||||
init() {
|
||||
this.syncFromLocation();
|
||||
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||
if (el.contains(this.$el)) {
|
||||
queueMicrotask(() => this.syncFromLocation());
|
||||
}
|
||||
});
|
||||
},
|
||||
selected: $el.dataset.currentValue,
|
||||
syncFromLocation() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const matchingOptions = Array.from(this.$el.options).filter((option) => {
|
||||
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||
|
||||
return optionUrl.pathname === currentUrl.pathname;
|
||||
});
|
||||
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|sentinel|')) || matchingOptions[0];
|
||||
|
||||
if (selectedOption) {
|
||||
this.selected = selectedOption.value;
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:livewire:navigated.window="syncFromLocation()"
|
||||
x-model="selected"
|
||||
x-on:change="
|
||||
const url = $event.target.value.split('|').slice(2).join('|');
|
||||
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||
">
|
||||
<optgroup label="Server">
|
||||
@foreach ($serverPageItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="Sentinel">
|
||||
@foreach ($sentinelMenuItems as $menuItem)
|
||||
<option value="navigate|sentinel|{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
@foreach ($sentinelMenuItems as $menuItem)
|
||||
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,201 @@
|
|||
<div class="sub-menu-wrapper">
|
||||
<a class="sub-menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">General</span></a>
|
||||
@if ($server->isFunctional())
|
||||
<a class="sub-menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Advanced</span>
|
||||
</a>
|
||||
@endif
|
||||
<a class="sub-menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Private Key</span>
|
||||
</a>
|
||||
@if ($server->hetzner_server_id || $server->vultr_instance_id)
|
||||
<a class="sub-menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Cloud Token</span>
|
||||
</a>
|
||||
@endif
|
||||
<a class="sub-menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">CA Certificate</span>
|
||||
</a>
|
||||
@if (!$server->isLocalhost())
|
||||
<a class="sub-menu-item {{ $activeMenu === 'cloudflare-tunnel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.cloudflare-tunnel', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Cloudflare Tunnel</span></a>
|
||||
@endif
|
||||
@if ($server->isFunctional())
|
||||
<a class="sub-menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Docker Cleanup</span>
|
||||
</a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Destinations</span>
|
||||
</a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.log-drains', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Log Drains</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.metrics', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Metrics</span></a>
|
||||
@endif
|
||||
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
|
||||
<a class="sub-menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Swarm</span>
|
||||
</a>
|
||||
@endif
|
||||
@if (!$server->isLocalhost())
|
||||
<a class="sub-menu-item {{ $activeMenu === 'danger' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.delete', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Danger</span></a>
|
||||
@endif
|
||||
@php
|
||||
$serverRouteParameters = ['server_uuid' => $server->uuid];
|
||||
$serverPageItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.show',
|
||||
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
|
||||
],
|
||||
[
|
||||
'label' => 'Proxy',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
|
||||
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
|
||||
],
|
||||
[
|
||||
'label' => 'Sentinel',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
||||
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'server.resources',
|
||||
'active' => request()->routeIs('server.resources'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
'visible' => auth()->user()?->can('update', $server),
|
||||
],
|
||||
];
|
||||
$serverMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'server.show',
|
||||
'active' => $activeMenu === 'general',
|
||||
],
|
||||
[
|
||||
'label' => 'Advanced',
|
||||
'route' => 'server.advanced',
|
||||
'active' => $activeMenu === 'advanced',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Private Key',
|
||||
'route' => 'server.private-key',
|
||||
'active' => $activeMenu === 'private-key',
|
||||
],
|
||||
[
|
||||
'label' => 'Cloud Token',
|
||||
'route' => 'server.cloud-provider-token',
|
||||
'active' => $activeMenu === 'cloud-provider-token',
|
||||
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
|
||||
],
|
||||
[
|
||||
'label' => 'CA Certificate',
|
||||
'route' => 'server.ca-certificate',
|
||||
'active' => $activeMenu === 'ca-certificate',
|
||||
],
|
||||
[
|
||||
'label' => 'Cloudflare Tunnel',
|
||||
'route' => 'server.cloudflare-tunnel',
|
||||
'active' => $activeMenu === 'cloudflare-tunnel',
|
||||
'visible' => ! $server->isLocalhost(),
|
||||
],
|
||||
[
|
||||
'label' => 'Docker Cleanup',
|
||||
'route' => 'server.docker-cleanup',
|
||||
'active' => $activeMenu === 'docker-cleanup',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Destinations',
|
||||
'route' => 'server.destinations',
|
||||
'active' => $activeMenu === 'destinations',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Log Drains',
|
||||
'route' => 'server.log-drains',
|
||||
'active' => $activeMenu === 'log-drains',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Metrics',
|
||||
'route' => 'server.metrics',
|
||||
'active' => $activeMenu === 'metrics',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Swarm',
|
||||
'route' => 'server.swarm',
|
||||
'active' => $activeMenu === 'swarm',
|
||||
'visible' => ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel,
|
||||
],
|
||||
[
|
||||
'label' => 'Danger',
|
||||
'route' => 'server.delete',
|
||||
'active' => $activeMenu === 'danger',
|
||||
'visible' => ! $server->isLocalhost(),
|
||||
],
|
||||
];
|
||||
|
||||
$serverPageItems = array_values(array_filter(
|
||||
$serverPageItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$serverMenuItems = array_values(array_filter(
|
||||
$serverMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$activeServerMenuItem = collect($serverMenuItems)->firstWhere('active', true);
|
||||
$activeServerPageItem = collect($serverPageItems)->firstWhere('active', true);
|
||||
$activeServerMobileItem = $activeServerMenuItem ?? $activeServerPageItem ?? $serverMenuItems[0];
|
||||
$activeServerMobileGroup = $activeServerMenuItem ? 'configuration' : 'server';
|
||||
$activeServerMobileNavigation = ($activeServerMobileItem['navigate'] ?? true) ? 'navigate' : 'location';
|
||||
$activeServerMenuValue = $activeServerMobileNavigation.'|'.$activeServerMobileGroup.'|'.route($activeServerMobileItem['route'], $serverRouteParameters);
|
||||
@endphp
|
||||
|
||||
<div class="w-full md:w-auto">
|
||||
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
|
||||
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||
<select id="server-mobile-section" class="select w-full" aria-label="Server menu"
|
||||
data-current-value="{{ $activeServerMenuValue }}"
|
||||
x-data="{
|
||||
init() {
|
||||
this.syncFromLocation();
|
||||
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||
if (el.contains(this.$el)) {
|
||||
queueMicrotask(() => this.syncFromLocation());
|
||||
}
|
||||
});
|
||||
},
|
||||
selected: $el.dataset.currentValue,
|
||||
current: $el.dataset.currentValue,
|
||||
syncFromLocation() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const selectedOption = Array.from(this.$el.options).find((option) => {
|
||||
if (!option.value.startsWith('navigate|') && !option.value.startsWith('location|')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||
|
||||
return optionUrl.pathname === currentUrl.pathname;
|
||||
});
|
||||
|
||||
if (selectedOption) {
|
||||
this.current = selectedOption.value;
|
||||
this.selected = selectedOption.value;
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:livewire:navigated.window="syncFromLocation()"
|
||||
x-model="selected"
|
||||
x-on:change="
|
||||
const value = $event.target.value;
|
||||
|
||||
if (value.startsWith('navigate|')) {
|
||||
const url = value.split('|').slice(2).join('|');
|
||||
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('location|')) {
|
||||
const url = value.split('|').slice(2).join('|');
|
||||
window.location.href = url;
|
||||
}
|
||||
">
|
||||
<optgroup label="Server">
|
||||
@foreach ($serverPageItems as $menuItem)
|
||||
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $serverRouteParameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
<optgroup label="Configuration">
|
||||
@foreach ($serverMenuItems as $menuItem)
|
||||
<option value="navigate|configuration|{{ route($menuItem['route'], $serverRouteParameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
@foreach ($serverMenuItems as $menuItem)
|
||||
<a class="sub-menu-item {{ $menuItem['active'] ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route($menuItem['route'], $serverRouteParameters) }}"><span class="menu-item-label">{{ $menuItem['label'] }}</span></a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
));
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
@foreach ($configurationMenuItems as $menuItem)
|
||||
<a @class([
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<h1>Configuration</h1>
|
||||
<livewire:project.shared.configuration-checker :resource="$database" />
|
||||
<livewire:project.database.heading :database="$database" />
|
||||
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.database.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}"><span class="menu-item-label">General</span></a>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
</x-slot>
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||
|
||||
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<div class="sub-menu-wrapper hidden md:flex">
|
||||
<a class="sub-menu-item" target="_blank" href="{{ $service->documentation() }}"><span class="menu-item-label">Documentation</span>
|
||||
<x-external-link /></a>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div>
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||
<div class="w-full">
|
||||
<x-slot:title>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div>
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
@if ($resourceType === 'database')
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Advanced | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="advanced" />
|
||||
<form wire:submit='submit' class="w-full">
|
||||
<div>
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
|
||||
type="number" min="1"
|
||||
label="Deployment timeout (seconds)" required
|
||||
label="Deployment timeout (sec)" required
|
||||
helper="You can define the maximum duration for a deployment to run before timing it out." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="deploymentQueueLimit"
|
||||
type="number" min="1"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > CA Certificate | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="ca-certificate" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Metrics | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="metrics" />
|
||||
<div class="w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
|
||||
<div class="w-full">
|
||||
@if ($server->hetzner_server_id || $server->vultr_instance_id)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Cloudflare Tunnel | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="cloudflare-tunnel" />
|
||||
<div class="w-full">
|
||||
<div class="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Delete Server | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="danger" />
|
||||
<div class="w-full">
|
||||
@if ($server->id !== 0)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Destinations | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="destinations" />
|
||||
<div class="w-full">
|
||||
@if ($server->isFunctional())
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Docker Cleanup | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="docker-cleanup" />
|
||||
<div class="w-full">
|
||||
<form wire:submit='submit'>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Log Drains | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="log-drains" />
|
||||
<div class="w-full">
|
||||
@if ($server->isFunctional())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="pb-6">
|
||||
<div class="pb-0 md:pb-6">
|
||||
<x-slide-over @startproxy.window="slideOverOpen = true" fullScreen closeWithX>
|
||||
<x-slot:title>Proxy Startup Logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
|
|
@ -12,14 +12,17 @@
|
|||
<livewire:activity-monitor header="Logs" fullHeight />
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1>Server</h1>
|
||||
<h1>Server</h1>
|
||||
<div class="pt-2 pb-4 md:pb-10">
|
||||
<div class="flex-col md:flex-row flex gap-2">
|
||||
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate">
|
||||
{{ data_get($server, 'name') }}
|
||||
</div>
|
||||
@php
|
||||
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
|
||||
@endphp
|
||||
@if ($server->proxySet() || $showSentinelStatus)
|
||||
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2">
|
||||
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-1">
|
||||
@if ($server->proxySet())
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($proxyStatus === 'running')
|
||||
|
|
@ -57,11 +60,10 @@ class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-
|
|||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="subtitle">{{ data_get($server, 'name') }}</div>
|
||||
</div>
|
||||
<div class="navbar-main">
|
||||
<nav
|
||||
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
|
||||
class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:flex md:w-auto md:overflow-visible">
|
||||
<a class="{{ request()->routeIs('server.show') ? 'dark:text-white' : '' }}" href="{{ route('server.show', [
|
||||
'server_uuid' => data_get($server, 'uuid'),
|
||||
]) }}" {{ wireNavigate() }}>
|
||||
|
|
@ -114,12 +116,70 @@ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar
|
|||
</a>
|
||||
@endcan
|
||||
</nav>
|
||||
<div class="order-first sm:order-last">
|
||||
<div class="order-first w-full md:order-last md:w-auto">
|
||||
<div>
|
||||
@if ($server->proxySet())
|
||||
@can('manageProxy', $server)
|
||||
<div id="server-mobile-actions" class="mt-2 mb-3 md:hidden">
|
||||
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
|
||||
@if ($proxyStatus === 'running')
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-nowrap gap-2 overflow-x-auto">
|
||||
<button type="button" class="button shrink-0"
|
||||
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
|
||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</g>
|
||||
</svg>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="button shrink-0 text-error"
|
||||
@click="document.getElementById('server-mobile-stop-proxy-trigger')?.click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||
</path>
|
||||
<path
|
||||
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||
</path>
|
||||
</svg>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="button shrink-0" target="_blank" href="http://{{ $serverIp }}:8080">
|
||||
Traefik Dashboard
|
||||
<x-external-link />
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<x-modal-confirmation title="Confirm Proxy Restart?" buttonTitle="Restart Proxy"
|
||||
submitAction="restart" :actions="[
|
||||
'This proxy will be stopped and started again.',
|
||||
'All resources hosted on coolify will be unavailable during the restart.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Proxy"
|
||||
:dispatchEvent="true" dispatchEventType="restartEvent">
|
||||
<x-slot:trigger>
|
||||
<button id="server-mobile-restart-proxy-trigger" type="button" class="hidden">Restart Proxy</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
<x-modal-confirmation title="Confirm Proxy Stopping?" buttonTitle="Stop Proxy"
|
||||
submitAction="stop(true)" :actions="[
|
||||
'The coolify proxy will be stopped.',
|
||||
'All resources hosted on coolify will be unavailable.',
|
||||
]" :confirmWithText="false"
|
||||
:confirmWithPassword="false" step2ButtonText="Stop Proxy" :dispatchEvent="true"
|
||||
dispatchEventType="stopEvent">
|
||||
<x-slot:trigger>
|
||||
<button id="server-mobile-stop-proxy-trigger" type="button" class="hidden">Stop Proxy</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
<div class="hidden gap-2 md:flex">
|
||||
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
|
||||
<x-loading text="Checking Traefik dashboard" />
|
||||
</div>
|
||||
|
|
@ -181,6 +241,7 @@ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar
|
|||
</svg>
|
||||
Start Proxy
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Private Key | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="private-key" />
|
||||
<div class="w-full">
|
||||
<div class="flex items-end gap-2">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Proxy Dynamic Configuration | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
|
||||
@if ($server->isFunctional())
|
||||
<div class="w-full">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Proxy Logs | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
|
||||
<div class="w-full">
|
||||
<h2 class="pb-4">Logs</h2>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
@if ($server->isFunctional())
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
|
||||
<div class="w-full">
|
||||
<livewire:server.proxy :server="$server" />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Server Resources | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-8 md:flex-row">
|
||||
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<div class="w-full">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
|
||||
<form wire:submit='submit' class="w-full">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Terminal Access | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
|
||||
<div class="w-full">
|
||||
<div>
|
||||
|
|
@ -52,4 +52,4 @@ class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Sentinel Logs | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
|
||||
<div class="w-full">
|
||||
<h2 class="pb-4">Logs</h2>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
@if ($server->isFunctional())
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
|
||||
<div class="w-full">
|
||||
<livewire:server.sentinel :server="$server" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="general" />
|
||||
<div class="w-full">
|
||||
<form wire:submit.prevent='submit' class="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{{ data_get_str($server, 'name')->limit(10) }} > Swarm | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="swarm" />
|
||||
<div class="w-full">
|
||||
<div>
|
||||
|
|
|
|||
48
tests/Feature/ForgotPasswordRateLimitTest.php
Normal file
48
tests/Feature/ForgotPasswordRateLimitTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
it('rate limits repeated forgot password attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => "user{$attempt}@example.com",
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'blocked@example.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 30)])
|
||||
->post('/forgot-password', [
|
||||
'email' => $email,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
|
@ -143,4 +143,107 @@ function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string
|
|||
|
||||
expect(file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')))
|
||||
->toContain('sub-menu-wrapper hidden md:flex');
|
||||
|
||||
$serverSidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
|
||||
expect($serverSidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="Server menu"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('<optgroup label="Configuration">')
|
||||
->toContain("'route' => 'server.proxy'")
|
||||
->toContain("'navigate' => false")
|
||||
->toContain("value.startsWith('location|')")
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
|
||||
$serverNavbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($serverNavbar)
|
||||
->toContain('server-mobile-actions')
|
||||
->toContain('Actions')
|
||||
->toContain('pb-0 md:pb-6')
|
||||
->toContain('navbar-main')
|
||||
->toContain('server-mobile-actions" class="mt-2 mb-3')
|
||||
->toContain('hidden min-h-10')
|
||||
->toContain('md:flex')
|
||||
->toContain('md:hidden')
|
||||
->toContain('flex flex-nowrap gap-2 overflow-x-auto')
|
||||
->toContain('server-mobile-restart-proxy-trigger')
|
||||
->toContain('server-mobile-stop-proxy-trigger')
|
||||
->toContain('class="button shrink-0"')
|
||||
->toContain('hidden gap-2 md:flex');
|
||||
|
||||
$serverMobileActions = serverMobileActionsMarkup($serverNavbar);
|
||||
|
||||
expect(strpos($serverMobileActions, 'Restart Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Stop Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Traefik Dashboard'));
|
||||
|
||||
foreach ([
|
||||
'views/components/server/sidebar-proxy.blade.php' => 'Proxy menu',
|
||||
'views/components/server/sidebar-sentinel.blade.php' => 'Sentinel menu',
|
||||
'views/components/server/sidebar-security.blade.php' => 'Security menu',
|
||||
] as $view => $label) {
|
||||
$sidebar = file_get_contents(resource_path($view));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="'.$label.'"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'views/livewire/server/show.blade.php',
|
||||
'views/livewire/server/advanced.blade.php',
|
||||
'views/livewire/server/private-key/show.blade.php',
|
||||
'views/livewire/server/ca-certificate/show.blade.php',
|
||||
'views/livewire/server/cloud-provider-token/show.blade.php',
|
||||
'views/livewire/server/cloudflare-tunnel.blade.php',
|
||||
'views/livewire/server/docker-cleanup.blade.php',
|
||||
'views/livewire/server/destinations.blade.php',
|
||||
'views/livewire/server/log-drains.blade.php',
|
||||
'views/livewire/server/charts.blade.php',
|
||||
'views/livewire/server/swarm.blade.php',
|
||||
'views/livewire/server/delete.blade.php',
|
||||
'views/livewire/server/proxy/show.blade.php',
|
||||
'views/livewire/server/proxy/logs.blade.php',
|
||||
'views/livewire/server/proxy/dynamic-configurations.blade.php',
|
||||
'views/livewire/server/sentinel/show.blade.php',
|
||||
'views/livewire/server/sentinel/logs.blade.php',
|
||||
'views/livewire/server/security/patches.blade.php',
|
||||
'views/livewire/server/security/terminal-access.blade.php',
|
||||
] as $view) {
|
||||
expect(file_get_contents(resource_path($view)))
|
||||
->toContain('gap-4 md:gap-8 md:flex-row')
|
||||
->toContain('md:flex-row')
|
||||
->not->toContain('sm:flex-row')
|
||||
->not->toContain('class="flex flex-col h-full gap-8 md:flex-row');
|
||||
}
|
||||
});
|
||||
|
||||
function serverMobileActionsMarkup(string $navbar): string
|
||||
{
|
||||
$actionsPosition = strpos($navbar, 'id="server-mobile-actions"');
|
||||
$restartModalPosition = strpos($navbar, '<x-modal-confirmation', $actionsPosition);
|
||||
|
||||
if ($actionsPosition === false || $restartModalPosition === false || $actionsPosition > $restartModalPosition) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($navbar, $actionsPosition, $restartModalPosition - $actionsPosition);
|
||||
}
|
||||
|
|
|
|||
72
tests/Feature/RegistrationRateLimitTest.php
Normal file
72
tests/Feature/RegistrationRateLimitTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
User::factory()->create(['id' => 0, 'email' => 'root@example.com']);
|
||||
});
|
||||
|
||||
it('rate limits repeated registration attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$attempt}",
|
||||
'email' => "attacker{$attempt}@example.com",
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'blocked@example.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('rate limits dotted plus-address variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 10)])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$index}",
|
||||
'email' => $email,
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
|
@ -82,18 +82,9 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
|
|||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('only audits sentinel pushes that dispatch a state update', function () use ($running) {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('sentinel.metrics_pushed', Mockery::on(function (array $context) {
|
||||
return $context['server_uuid'] === $this->server->uuid
|
||||
&& $context['team_id'] === $this->team->id;
|
||||
}));
|
||||
it('does not audit successful sentinel pushes', function () use ($running) {
|
||||
Log::shouldReceive('channel')->with('audit')->never();
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\Navbar;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
|
|
@ -52,3 +53,31 @@ function makeNavbarServer(bool $isFunctional): array
|
|||
->assertSee('Sentinel')
|
||||
->assertSee('In sync');
|
||||
});
|
||||
|
||||
it('listens for sentinel restarted broadcasts', function () {
|
||||
[$server, , $team] = makeNavbarServer(isFunctional: true);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertSet('server.uuid', $server->uuid);
|
||||
|
||||
expect(app(Navbar::class)->getListeners())
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelRestarted", 'refreshSentinelStatus');
|
||||
});
|
||||
|
||||
it('refreshes sentinel status when sentinel restarts for the server', function () {
|
||||
[$server] = makeNavbarServer(isFunctional: true);
|
||||
$server->forceFill(['sentinel_updated_at' => now()->subDay()])->save();
|
||||
|
||||
expect($server->fresh()->isSentinelLive())->toBeFalse();
|
||||
|
||||
$component = Livewire::test('server.navbar', ['server' => $server->fresh()])
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('Out of sync');
|
||||
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
$component
|
||||
->call('refreshSentinelStatus', ['serverUuid' => $server->uuid])
|
||||
->assertSee('In sync')
|
||||
->assertDontSee('Out of sync');
|
||||
});
|
||||
|
|
|
|||
16
tests/Unit/EmailIdentityHelperTest.php
Normal file
16
tests/Unit/EmailIdentityHelperTest.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
it('normalizes plus and dotted email identity variants', function () {
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
});
|
||||
|
||||
it('returns null for blank or malformed email identities', function (?string $email) {
|
||||
expect(normalize_email_identity($email))->toBeNull();
|
||||
})->with([
|
||||
null,
|
||||
'',
|
||||
'not-an-email',
|
||||
'@example.com',
|
||||
'local@',
|
||||
]);
|
||||
19
tests/Unit/ServerHeaderLayoutTest.php
Normal file
19
tests/Unit/ServerHeaderLayoutTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
test('server header renders subtitle before status badges', function () {
|
||||
$navbar = file_get_contents(__DIR__.'/../../resources/views/livewire/server/navbar.blade.php');
|
||||
|
||||
$titlePosition = strpos($navbar, '<h1>Server</h1>');
|
||||
$subtitlePosition = strpos($navbar, 'data-testid="server-subtitle"');
|
||||
$statusPosition = strpos($navbar, 'data-testid="server-status-summary"');
|
||||
|
||||
expect($navbar)->not->toContain('class="subtitle"')
|
||||
->not->toContain('text-neutral-300')
|
||||
->toContain('text-neutral-600 dark:text-neutral-400');
|
||||
|
||||
expect($titlePosition)->not->toBeFalse()
|
||||
->and($subtitlePosition)->not->toBeFalse()
|
||||
->and($statusPosition)->not->toBeFalse()
|
||||
->and($titlePosition)->toBeLessThan($subtitlePosition)
|
||||
->and($subtitlePosition)->toBeLessThan($statusPosition);
|
||||
});
|
||||
Loading…
Reference in a new issue