docs: add projects directory with 4 projects (#2500)

This commit is contained in:
Sam Judelson 2024-04-17 19:30:31 -04:00 committed by GitHub
parent c1ebaf9d04
commit 2960dada4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
123 changed files with 7920 additions and 0 deletions

View File

@ -0,0 +1,5 @@
/target
meilisearch
data.ms
dumps
Cargo.lock

View File

@ -0,0 +1,61 @@
[package]
name = "meilisearch_searchbar"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = {version = "0.6.5",features = ["nightly"]}
leptos_axum = { version = "0.6.5", optional = true}
meilisearch-sdk = { version = "0.24.3", optional = true}
axum = {version = "0.7.4", optional = true}
leptos_meta = {version = "0.6.5",features = ["nightly"]}
leptos_router = {version = "0.6.5",features = ["nightly"]}
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
log = "0.4.20"
tower = {verison= "0.4.13", optional=true}
tower-http = {version = "0.5.1", optional = true, features = ["fs"]}
simple_logger = {version = "4.3.3", optional = true}
tokio = { version = "1", features = ["full"], optional = true }
lazy_static = { version = "1.4.0", optional = true }
serde = "1.0.196"
serde_json = "1.0.113"
csv = {version = "1.3.0", optional=true}
[features]
default = ["ssr"]
hydrate = ["leptos/hydrate","leptos_meta/hydrate","leptos_router/hydrate"]
ssr = [
"tokio",
"lazy_static",
"simple_logger",
"dep:meilisearch-sdk",
"dep:axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"tower",
"tower-http",
"leptos_axum",
"csv",
]
lazy_static = ["dep:lazy_static"]
[package.metadata.leptos]
output-name = "meilisearch_searchbar"
site-root = "target/site"
site-pkg-dir = "pkg"
assets-dir = "public"
site-addr = "127.0.0.1:3000"
reload-port = 3001
browserquery = "defaults"
watch = false
env = "DEV"
bin-features = ["ssr"]
bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false

View File

@ -0,0 +1,4 @@
[tasks.ci]
description = "Continuous Integration task"
command = "cargo"
args = ["test"]

View File

@ -0,0 +1,28 @@
# Meilisearch Searchbar
This show how to integrate meilisearch with a leptos app, including a search bar and showing the results to the user.
<br><br>
We'll run meilisearch locally, as opposed to using their cloud service.
<br><br>
To get started install meilisearch into this example's root.
```sh
curl -L https://install.meilisearch.com | sh
```
Run it.
```sh
./meilisearch
```
Then set the environment variable and serve the app. I've included the address of my own local meilisearch server.
I didn't provide a password to meilisearch during my setup, and I didn't provide one in my environment variables either.
```sh
MEILISEARCH_URL=http://localhost:7700 && cargo leptos serve
```
Navigate to 127.0.0.1:3000 and start typing in popular American company names. (Boeing, Pepsi, etc)
## Thoughts, Feedback, Criticism, Comments?
Send me any of the above, I'm @sjud on leptos discord. I'm always looking to improve and make these projects more helpful for the community. So please let me know how I can do that. Thanks!

View File

@ -0,0 +1,504 @@
name,last,high,low,absolute_change,percent_change,vol
"Boeing","209.16","211.41","207.91",-0.06,-0.03,4310000
"General Motors","38.56","38.97","38.45",-0.09,-0.23,11720000
"Chevron","151.02","155.31","150.99",-3.04,-1.97,8040000
"Citigroup","53.98","54.44","53.53",-0.31,-0.57,11750000
"Bank of America","33.05","33.25","32.83",-0.07,-0.2,30560000
"AT&T","16.83","16.88","16.57",0.01,0.06,40000000
"Caterpillar","316.89","322.33","315.57",-5.11,-1.59,2780000
"Intel","43.31","43.52","42.40",0.81,1.91,43560000
"Microsoft","420.55","420.82","415.09",6.44,1.56,21300000
"Ford Motor","12.68","12.92","12.64",-0.15,-1.17,44810000
"eBay","42.43","42.69","41.79",0.41,0.98,4340000
"Walt Disney","108.37","110.14","107.69",-2.17,-1.96,19100000
"Dow","53.99","54.12","53.56",0.11,0.2,3730000
"Cisco","50.10","50.26","49.63",0.15,0.31,21620000
"Deere&Company","381.32","385.30","380.46",-4.51,-1.17,1410000
"FedEx","242.59","242.82","240.14",0.84,0.35,1720000
"General Mills","62.34","63.86","62.09",-1.73,-2.69,4070000
"Corning","32.04","32.05","31.58",0.31,0.98,2760000
"Goldman Sachs","384.21","386.13","382.58",-0.83,-0.22,1620000
"JPMorgan","174.96","175.10","173.67",0.16,0.09,4270000
"Kimberly-Clark","119.81","120.47","119.12",-0.46,-0.38,1570000
"Kraft Heinz","35.97","36.38","35.80",-0.5,-1.37,8020000
"Coca-Cola","59.56","59.58","59.03",-0.27,-0.45,12020000
"McDonalds","289.41","292.49","288.94",-2.52,-0.86,3140000
"Eli Lilly","739.51","745.68","733.61",3.83,0.52,2410000
"Oracle","116.60","117.34","115.75",-0.08,-0.07,4990000
"Merck&Co","125.43","126.69","125.04",-1.18,-0.93,5890000
"Motorola","330.98","333.00","323.23",2.63,0.8,1180000
"3M","92.89","93.30","92.39",-0.31,-0.33,3620000
"Vertex","422.91","425.96","419.51",-0.15,-0.04,1100000
"Monster Beverage","55.66","56.57","55.35",-0.83,-1.47,3760000
"Fifth Third","33.65","33.74","33.02",0.26,0.78,4030000
"Cintas","618.22","620.32","614.07",1.06,0.17,248630
"Autodesk","266.68","269.53","262.85",5.17,1.98,1730000
"Gilead","73.67","74.11","72.81",-0.13,-0.18,10150000
"Alphabet A","149.00","149.44","146.18",3.09,2.12,26030000
"Fiserv","144.23","144.34","142.74",0.68,0.47,1730000
"Adobe","627.21","628.07","615.80",11.35,1.84,1950000
"Qualcomm","151.00","153.40","148.35",2.82,1.9,11310000
"Warner Bros Discovery","9.64","9.89","9.57",-0.19,-1.93,22450000
"Applied Materials","185.84","186.15","178.62",11.95,6.87,9240000
"Steel Dynamics","124.99","125.66","124.21",0.15,0.12,1120000
"Cadence Design","311.94","313.11","308.82",4.58,1.49,1300000
"Microchip","85.44","86.00","84.67",1.09,1.29,4750000
"Wynn Resorts","105.60","106.89","104.26",-0.51,-0.48,2400000
"Intuitive Surgical","388.22","389.92","383.05",1.28,0.33,1100000
"Nasdaq Inc","57.25","57.35","56.65",0.49,0.86,1250000
"Henry Schein","73.72","75.18","73.43",-1.18,-1.58,1330000
"Paychex","123.03","123.25","121.84",0.28,0.23,1680000
"VeriSign","198.38","203.00","197.08",-2.64,-1.31,1470000
"Apple","188.85","189.99","188.00",0.77,0.41,43180000
"Fastenal","70.02","70.06","69.16",0.76,1.1,2610000
"Dentsply","32.79","33.16","32.73",-0.37,-1.12,3230000
"Zions","40.09","40.41","39.45",0.2,0.5,2070000
"Northern Trust","79.64","79.73","78.25",1.17,1.49,1120000
"CH Robinson","74.67","74.85","73.59",0.01,0.01,775730
"PACCAR","106.01","106.08","104.96",0.4,0.38,1400000
"Amazon.com","174.45","175.00","170.58",4.61,2.71,52940000
"Ross Stores","145.87","146.42","145.20",-0.19,-0.13,1260000
"NetApp","89.85","90.33","88.78",0.93,1.05,1360000
"Garmin","123.21","123.70","121.90",0.89,0.73,580350
"Costco","723.40","725.53","720.12",-0.76,-0.1,1370000
"Lam Research","911.58","913.82","874.86",47.22,5.46,1820000
"Intuit","658.16","662.83","654.69",5.09,0.78,923900
"Expedia","131.11","132.80","126.05",-28.36,-17.78,18410000
"Cognizant A","77.10","78.57","76.87",-1.2,-1.53,5250000
"Akamai","128.32","129.16","127.04",1.54,1.21,1690000
"KLA Corp","649.80","651.26","628.55",31.31,5.06,1240000
"Juniper","37.03","37.06","36.93",0.08,0.22,2540000
"Amgen","291.12","295.00","289.72",-3.73,-1.27,3270000
"Expeditors Washington","127.39","127.39","125.52",0.21,0.17,837390
"Electronic Arts","140.61","140.98","137.00",1.76,1.27,1950000
"T Rowe","106.33","109.83","105.90",-2.58,-2.37,2420000
"Biogen","240.98","241.36","238.90",0.68,0.28,665370
"Charles Schwab","63.37","63.41","62.35",0.9,1.44,4530000
"Huntington Bancshares","12.41","12.48","12.19",0.09,0.73,13750000
"Gen Digital","21.35","21.39","20.94",0.48,2.3,5700000
"NVIDIA","721.33","721.85","702.12",24.92,3.58,42810000
"Starbucks","97.30","97.99","96.31",0.71,0.74,9300000
"Cincinnati Financial","108.89","109.48","107.12",1.29,1.2,610810
"Axon Enterprise","270.98","271.39","268.44",1.98,0.74,191750
"Hologic","73.44","74.14","73.09",0.01,0.01,1460000
"Comcast","42.07","42.28","41.22",0.82,1.99,26670000
"Medtronic","84.97","86.15","84.46",-1.21,-1.4,7820000
"Dover","160.46","160.93","159.36",0.35,0.22,999650
"Northrop Grumman","454.99","455.56","450.52",3.77,0.84,752290
"MGM","46.74","46.86","46.21",0.31,0.67,3840000
"Mastercard","457.74","458.98","456.37",-0.52,-0.11,2050000
"General Dynamics","270.19","271.36","269.05",1.19,0.44,728860
"DTE Energy","104.43","104.67","103.14",0.2,0.19,885020
"Analog Devices","195.02","195.53","193.45",0.96,0.49,3150000
"VF","15.30","15.50","15.04",-0.05,-0.33,7200000
"Cardinal Health","104.94","105.42","103.28",2.2,2.15,1990000
"Xcel Energy","58.17","58.20","57.67",0.17,0.29,2320000
"DR Horton","144.93","145.31","142.95",0.34,0.24,1400000
"IPG","30.76","31.71","30.60",-1.04,-3.27,6450000
"Lockheed Martin","426.42","428.00","424.47",-0.58,-0.14,887810
"Waters","322.90","327.57","320.82",-3.79,-1.16,397410
"Accenture","371.63","372.48","367.95",3.15,0.85,1420000
"Dominion Energy","44.70","44.72","43.92",0.52,1.18,2710000
"Exxon Mobil","101.75","104.84","101.70",-2.22,-2.14,16690000
"Cigna","334.85","335.83","330.67",2.56,0.77,1300000
"Public Service Enterprise","58.41","58.62","57.83",0.21,0.36,1360000
"NiSource","25.24","25.26","24.96",0.18,0.72,2330000
"Zimmer Biomet","122.78","125.31","121.19",-0.26,-0.21,3110000
"CSX","36.89","37.22","36.74",-0.18,-0.49,7940000
"ICE","135.52","135.69","132.92",1.4,1.04,3850000
"Southwest Airlines","32.50","32.64","31.80",0.17,0.53,8060000
"Illinois Tool Works","255.71","255.77","253.63",1.2,0.47,655100
"Darden Restaurants","167.65","168.34","167.16",-0.53,-0.32,549580
"Truist Financial Corp","35.83","36.07","34.88",0.32,0.9,9970000
"Halliburton","34.50","35.15","34.43",-0.53,-1.51,4550000
"Prologis","132.45","132.77","130.45",0.79,0.6,2780000
"McCormick&Co","64.64","65.94","64.33",-1.18,-1.79,1390000
"Host Hotels Resorts","19.58","19.62","19.32",0.03,0.15,4010000
"Estee Lauder","143.33","145.23","140.61",2.56,1.82,2340000
"International Paper","35.26","35.32","34.63",0.37,1.06,3130000
"Emerson","103.19","103.32","102.04",0.81,0.79,3340000
"Clorox","153.12","155.46","152.44",-1.1,-0.71,753850
"ConocoPhillips","111.11","114.26","111.02",-2.79,-2.45,5070000
"Colgate-Palmolive","83.46","84.26","83.18",-0.8,-0.95,3460000
"Pinnacle West","67.02","67.02","66.18",0.63,0.95,944540
"Regions Financial","17.99","18.10","17.72",0.1,0.56,9480000
"CenterPoint Energy","27.53","27.60","27.30",0.1,0.36,2090000
"MetLife","67.49","67.67","66.40",0.74,1.11,4590000
"Exelon","33.84","33.90","33.35",0.09,0.27,6090000
"Baxter","39.55","40.52","38.79",-0.9,-2.24,4580000
"Occidental","57.46","58.34","57.24",-0.59,-1.02,7450000
"Southern","66.91","67.20","66.50",-0.03,-0.04,3250000
"Tapestry","42.01","42.80","41.27",-0.98,-2.28,6310000
"Lennar","153.03","153.80","151.32",-0.26,-0.17,1370000
"Campbell Soup","42.00","42.91","41.72",-1.1,-2.55,2910000
"State Street","72.81","72.98","72.11",0.43,0.59,1270000
"Progressive","182.52","184.00","182.30",-0.44,-0.24,1710000
"Vulcan Materials","240.05","240.10","237.40",1.61,0.68,1020000
"Parker-Hannifin","521.42","521.50","513.99",5.91,1.15,472760
"Genuine Parts","143.21","143.21","141.43",0.9,0.63,742180
"CBRE A","86.54","86.64","84.77",0.58,0.67,1230000
"DuPont De Nemours","67.66","67.72","66.57",0.56,0.83,3220000
"Sherwin-Williams","311.77","312.99","309.39",-0.38,-0.12,885490
"Pfizer","27.55","27.59","27.38",-0.01,-0.05,24950000
"Wells Fargo&Co","48.05","48.35","47.47",-0.3,-0.62,13910000
"Walmart","169.27","169.73","168.92",-0.1,-0.06,3930000
"Edison","64.69","64.96","64.34",-0.03,-0.05,1470000
"Snap-On","262.24","268.14","261.30",-3.79,-1.42,459630
"Equifax","249.23","253.48","246.07",-2.83,-1.12,828980
"McKesson","501.35","504.31","494.50",7.12,1.44,1030000
"Entergy","97.94","98.02","96.80",0.54,0.55,917720
"CMS Energy","56.06","56.11","55.61",0.31,0.55,1290000
"Ameriprise Financial","396.45","398.15","392.95",1.62,0.41,235380
"AIG","69.11","69.23","68.06",0.7,1.03,3020000
"Ralph Lauren A","175.04","175.40","169.60",3.19,1.86,1730000
"Bath & Body Works","44.74","44.78","43.79",0.37,0.83,1500000
"IFF","79.93","80.30","78.72",-0.58,-0.72,3130000
"WW Grainger","959.38","961.91","946.13",11.31,1.19,194930
"Constellation Brands A","242.37","244.51","241.65",-0.68,-0.28,1170000
"American Tower","194.41","194.49","191.64",0.77,0.4,1710000
"Philip Morris","89.12","89.43","88.55",0.11,0.12,4300000
"Fidelity National Info","61.78","62.11","61.40",0.34,0.55,2680000
"Altria","40.10","40.15","39.88",0.01,0.02,8110000
"Ball","59.16","59.26","58.21",0.02,0.03,1350000
"Hartford","90.80","90.91","89.31",1.07,1.19,1510000
"Hershey Co","195.39","201.79","194.68",-6.92,-3.42,2950000
"Morgan Stanley","85.88","86.03","85.32",0.23,0.27,4620000
"PNC Financial","147.76","148.38","146.68",-0.17,-0.11,1120000
"Waste Management","188.87","189.78","187.56",-0.62,-0.33,1570000
"Cencora Inc","230.69","231.60","228.77",0.37,0.16,1370000
"Assurant","174.48","175.06","171.86",-0.09,-0.05,220360
"Kroger","45.41","45.54","45.10",-0.03,-0.07,3120000
"Molson Coors Brewing B","60.23","60.24","59.37",-0.04,-0.06,2060000
"Home Depot","363.09","364.43","360.80",-0.63,-0.17,1760000
"Becton Dickinson","243.66","244.05","240.37",2.95,1.23,2040000
"JM Smucker","127.90","130.43","127.67",-3.19,-2.43,894090
"Best Buy","75.58","75.68","74.76",0.09,0.12,1410000
"Archer-Daniels-Midland","53.07","53.26","52.34",0.37,0.7,4250000
"Brown Forman","56.59","56.94","56.31",-0.34,-0.6,847500
"IBM","186.33","187.18","183.86",1.97,1.07,4970000
"Union Pacific","249.45","249.55","246.51",0.63,0.25,1860000
"Micron","85.56","85.62","83.96",0.68,0.8,12070000
"Avery Dennison","204.92","205.15","202.01",1.95,0.96,409860
"Marathon Oil","22.45","22.87","22.37",-0.25,-1.1,8040000
"CF Industries","78.08","78.51","76.67",1.57,2.05,1780000
"APA Corp","29.87","30.69","29.80",-0.6,-1.97,5240000
"Duke Energy","91.69","92.72","91.63",-0.96,-1.04,3900000
"KeyCorp","13.88","13.97","13.64",0.01,0.11,13200000
"Laboratory America","222.58","223.60","222.18",-0.22,-0.1,376300
"Boston Properties","64.18","65.36","63.60",-0.89,-1.37,1240000
"Western Digital","56.82","57.35","56.25",-0.3,-0.53,5200000
"PPG Industries","139.58","139.61","137.46",0.95,0.69,949590
"S&P Global","438.02","441.04","431.33",1.39,0.32,1770000
"Williams","34.03","34.26","33.85",-0.01,-0.01,5830000
"Elevance Health","506.07","506.82","500.18",3.92,0.78,700780
"Jacobs Engineering","145.54","145.54","143.12",2.37,1.66,737440
"Eastman Chemical","82.32","82.83","81.90",-0.67,-0.81,622510
"Verizon","39.72","40.09","39.26",-0.19,-0.49,15090000
"Nucor","186.52","187.10","185.18",0.23,0.12,1170000
"Omnicom","84.58","87.09","84.45",-2.05,-2.37,1550000
"AvalonBay","174.62","174.63","173.20",0.09,0.05,573430
"Marriott Int","247.02","250.75","245.45",-2.56,-1.03,1370000
"Ingersoll Rand","85.89","86.51","85.27",0.51,0.6,3670000
"Bristol-Myers Squibb","49.80","49.83","48.49",1.09,2.24,13400000
"American Electric Power","76.66","76.73","75.57",0.72,0.95,2010000
"Thermo Fisher Scientific","550.82","554.13","548.29",-0.07,-0.01,1160000
"Newmont Goldcorp","32.79","33.22","32.54",-0.55,-1.65,10450000
"Public Storage","284.04","286.34","280.59",-0.86,-0.3,544300
"Travelers","214.50","214.99","212.04",0.69,0.32,793950
"Stanley Black Decker","88.93","89.45","88.56",-0.34,-0.38,778810
"Franklin Resources","27.09","27.12","26.58",0.26,0.97,2210000
"Humana","370.16","371.16","366.30",2.36,0.64,1110000
"Paramount Global B","12.90","13.17","12.84",-0.11,-0.85,9380000
"Chubb","247.20","247.26","243.32",2.86,1.17,1370000
"J&J","156.74","157.20","155.71",0.34,0.22,6280000
"Tyson Foods","52.58","53.83","52.15",-1.39,-2.58,3100000
"Target","146.51","147.57","146.32",-0.89,-0.6,2750000
"Jabil Circuit","139.72","140.26","136.05",3.8,2.79,1270000
"American Express","212.40","214.24","210.42",1.19,0.56,4100000
"Masco","72.61","74.20","72.17",-0.71,-0.97,3640000
"Stryker","341.85","344.33","337.63",2.82,0.83,1370000
"Discover","109.19","109.44","108.03",0.35,0.32,1050000
"Prudential Financial","105.58","107.65","105.00",-3.03,-2.79,2000000
"Abbott Labs","111.78","112.63","111.19",-0.65,-0.58,5560000
"General Electric","139.27","139.42","138.21",0.22,0.16,2900000
"Quest Diagnostics","126.73","127.60","125.92",0.19,0.15,473100
"United Parcel Service","146.20","147.83","145.91",-1.72,-1.16,2280000
"CVS Health Corp","76.32","76.35","74.46",1.24,1.65,7350000
"PPL","25.87","25.89","25.54",0.21,0.82,5330000
"Robert Half","81.15","81.62","80.07",0.6,0.74,1010000
"Simon Property","146.94","147.37","144.06",2.84,1.97,1790000
"Johnson Controls","55.51","55.72","55.10",0.4,0.72,4240000
"Cummins","251.84","251.91","248.73",1.92,0.77,572600
"Allstate","160.06","160.59","158.53",-1.69,-1.04,1280000
"Sempra Energy","69.67","70.03","69.28",-0.17,-0.24,1220000
"Devon Energy","41.59","42.58","41.35",-0.82,-1.93,7220000
"Conagra Brands","27.39","27.99","27.08",-0.7,-2.51,5110000
"TJX","98.73","99.07","98.01",0.37,0.38,3420000
"Whirlpool","109.00","110.43","108.64",-1.19,-1.08,669950
"FirstEnergy","37.31","37.54","36.00",1.52,4.26,9590000
"Globe Life","125.91","125.98","123.99",0.96,0.77,474680
"Rtx Corp","90.52","91.61","90.33",-0.52,-0.57,5870000
"PulteGroup","103.09","103.93","102.15",-0.17,-0.16,1090000
"Valero Energy","143.08","143.27","141.16",1.28,0.9,3460000
"Boston Scientific","65.49","65.60","64.85",0.47,0.72,6750000
"Capital One Financial","135.17","135.50","133.35",0.39,0.29,1330000
"PG E","16.25","16.39","16.18",0.01,0.03,18190000
"Norfolk Southern","254.77","256.36","253.69",0.72,0.28,900100
"Aflac","78.20","78.36","77.56",0.1,0.12,1500000
"Equity Residential","58.86","59.28","58.65",-0.34,-0.57,1440000
"Air Products","219.79","219.93","216.50",-0.12,-0.05,2170000
"Principal Financial","78.20","78.47","76.82",0.44,0.57,828190
"Texas Instruments","162.40","162.47","160.63",2.19,1.37,3820000
"HP Inc","28.42","28.52","28.19",0.13,0.46,4110000
"Honeywell","194.84","195.23","192.83",1.38,0.71,3570000
"AMD","172.48","175.10","168.66",3.13,1.85,55790000
"M&T Bank","133.40","133.51","130.09",1.78,1.35,1000000
"Mosaic","29.91","30.49","29.82",-0.17,-0.58,5290000
"Revvity","103.74","104.26","102.86",0.49,0.47,483210
"Las Vegas Sands","53.62","53.85","53.09",0.01,0.01,3320000
"Freeport-McMoran","37.31","37.91","37.26",-0.81,-2.12,14230000
"AutoZone","2680.21","2743.53","2680.00",-51.45,-1.88,195110
"Sysco","79.57","79.63","79.02",0.25,0.32,2120000
"Ameren","68.67","68.83","67.59",0.76,1.11,1840000
"Eaton","277.95","278.58","273.00",4.74,1.73,1530000
"Salesforce Inc","291.27","295.24","291.07",-0.68,-0.23,3730000
"Consolidated Edison","89.05","89.14","88.32",0.38,0.43,1400000
"The AES","16.46","16.48","16.14",0.26,1.6,5390000
"Textron","87.18","88.00","87.00",-0.41,-0.47,958370
"U.S. Bancorp","40.18","40.39","39.77",-0.19,-0.47,8700000
"Comerica","51.34","51.84","50.14",0.27,0.53,1370000
"Visa A","276.40","277.18","274.09",0.62,0.22,2850000
"Baker Hughes","29.06","29.46","28.83",-0.26,-0.89,7090000
"Hess","142.04","147.65","142.02",-4.23,-2.89,4550000
"Yum! Brands","130.27","130.41","129.32",-0.13,-0.1,1920000
"Marsh McLennan","197.88","197.92","195.94",1.08,0.55,800330
"Kellanova","53.48","54.74","53.12",-1.46,-2.65,3240000
"Kimco Realty","20.10","20.10","19.65",0.08,0.4,6400000
"Ecolab","202.67","203.62","200.50",-0.22,-0.11,972500
"EOG Resources","111.05","113.75","110.67",-2.01,-1.78,2470000
"Aon","312.47","312.55","306.67",5.29,1.72,753950
"Hasbro","50.59","51.10","50.23",-0.09,-0.18,1320000
"Bank of NY Mellon","55.21","55.36","54.83",0.1,0.18,2440000
"Schlumberger","47.07","47.85","46.91",-0.72,-1.51,8170000
"Walgreens Boots","22.24","22.63","22.16",-0.3,-1.33,8340000
"Rockwell Automation","283.61","284.58","278.77",4.9,1.76,1490000
"PepsiCo","167.67","171.39","166.97",-6.18,-3.55,12240000
"UnitedHealth","518.11","520.39","516.34",-1.98,-0.38,2600000
"Teradyne","102.31","102.42","98.86",3.84,3.9,1640000
"Danaher","242.88","247.25","242.88",-2.99,-1.22,2030000
"Seagate","89.41","89.47","87.36",1.73,1.97,1520000
"Agilent Technologies","133.38","135.15","132.77",-0.69,-0.51,864180
"Delta Air Lines","40.51","40.87","39.96",0.15,0.38,7570000
"Moodys","404.99","405.32","396.40",6.99,1.76,920590
"Nike","104.50","104.93","103.33",0.73,0.7,4430000
"Procter&Gamble","157.40","158.34","156.96",-1.24,-0.78,5860000
"Weyerhaeuser","33.28","33.28","32.97",0.27,0.82,2300000
"ADP","249.99","250.99","248.89",-1.09,-0.43,1190000
"Keurig Dr Pepper","31.15","31.49","30.94",-0.34,-1.08,5120000
"Lowes","222.27","222.31","219.17",1.88,0.85,1070000
"United Airlines Holdings","42.33","43.03","41.60",0.71,1.71,9110000
"Netflix","561.32","566.00","558.10",2.79,0.5,3020000
"News Corp","27.21","27.34","26.88",0.2,0.74,874170
"Equinix","855.76","856.82","844.90",0.43,0.05,354810
"Booking","3758.18","3761.75","3663.01",-82.04,-2.14,395700
"OReilly Automotive","1025.82","1041.33","1023.58",4.99,0.49,484720
"BlackRock","796.68","800.80","792.23",3.48,0.44,426020
"CME Group","205.09","205.60","203.61",1.09,0.53,1420000
"Illumina","137.84","147.70","135.30",-5.49,-3.83,3470000
"JB Hunt","215.58","215.90","211.95",2.2,1.03,495810
"Loews","72.74","72.75","71.66",0.72,1,475990
"NextEra Energy","56.56","56.63","55.72",0.27,0.48,7840000
"First Solar","151.50","153.01","144.00",8.33,5.82,2730000
"Viatris","11.68","11.69","11.45",0.12,1.04,6170000
"F5 Networks","186.61","187.21","184.44",1.38,0.74,304830
"Edwards Lifesciences","85.02","86.68","84.89",-0.78,-0.91,2830000
"News Corp A","26.04","26.18","25.70",0.2,0.77,3940000
"Amphenol","105.28","105.41","104.55",0.42,0.4,1250000
"Berkshire Hathaway B","398.33","398.33","395.85",0.84,0.21,1920000
"Coterra Energy","24.30","24.52","24.20",-0.2,-0.82,4840000
"CarMax","74.79","74.86","72.90",1.49,2.03,1560000
"Chipotle Mexican Grill","2636.06","2659.11","2615.93",15.58,0.59,191430
"DaVita","109.87","111.27","109.73",-0.95,-0.86,677110
"EQT","34.32","34.81","34.21",-0.43,-1.24,3190000
"FMC","51.76","52.41","50.63",-0.28,-0.54,3270000
"L3Harris Technologies","209.84","210.11","207.87",0.54,0.26,517540
"Healthpeak Properties","17.35","17.80","17.07",-0.64,-3.56,13020000
"Welltower","87.64","87.68","86.23",0.67,0.76,1720000
"Hormel Foods","29.07","29.33","28.97",-0.41,-1.37,3560000
"Invesco","15.89","15.95","15.68",-0.01,-0.09,2870000
"Iron Mountain","68.63","68.94","68.18",0.07,0.1,642430
"Eversource Energy","54.95","55.27","53.88",0.88,1.63,3160000
"NRG","52.37","52.56","51.98",0.02,0.04,1720000
"ONEOK","69.03","69.49","68.71",0.07,0.09,2080000
"Pioneer Natural","227.15","233.37","226.85",-4.61,-1.99,1520000
"Republic Services","173.49","175.07","172.79",-1.15,-0.66,929990
"Roper Technologies","550.26","550.77","545.11",4.92,0.9,370800
"Ventas","45.52","45.68","44.99",0,0,1630000
"WEC Energy","77.57","77.74","76.95",0.17,0.22,2060000
"Blackstone","127.68","128.63","125.99",-0.16,-0.13,2640000
"Marathon Petroleum","169.97","170.69","168.73",0.29,0.17,2130000
"Broadcom","1283.44","1285.65","1249.08",8.68,0.68,2550000
"NXP","233.55","233.66","228.32",5.72,2.51,2340000
"Tesla","193.57","194.12","189.49",4.01,2.12,83610000
"Take-Two","154.91","158.11","152.23",-14.69,-8.66,6580000
"Dollar Tree","139.50","140.73","139.17",-1.4,-0.99,2290000
"Align","296.37","298.54","291.53",1.37,0.46,803120
"ANSYS","342.28","346.64","341.89",-3.05,-0.88,834790
"Builders FirstSource","185.38","186.54","182.89",0.13,0.07,1040000
"Charter Communications","291.15","293.40","285.01",8.55,3.03,2050000
"CoStar","83.13","83.45","80.51",1.36,1.66,4010000
"DexCom","120.47","124.99","120.29",-6.58,-5.18,5550000
"Fortinet","70.44","70.86","68.20",2.59,3.82,7090000
"IDEXX Labs","572.21","578.35","565.52",-1.4,-0.24,312340
"Incyte","57.66","57.96","57.09",0.3,0.52,1510000
"Jack Henry&Associates","175.93","176.10","174.01",1.88,1.08,448190
"MarketAxesss","223.32","226.60","220.29",-0.87,-0.39,280200
"Monolithic","752.31","761.50","737.22",15.24,2.07,654900
"Nordson","263.63","263.94","260.81",1.76,0.67,143780
"ON Semiconductor","80.80","81.59","79.71",0.09,0.11,6140000
"PTC","183.10","183.69","181.60",1.56,0.86,527660
"Insulet","192.44","197.46","191.92",-4.2,-2.14,736300
"Pool","386.65","391.01","384.42",-0.69,-0.18,211450
"Bio-Techne","67.95","68.19","66.79",0.05,0.07,883500
"Zebra","253.09","253.73","248.36",5.97,2.42,324810
"BorgWarner","31.80","32.00","31.29",0.32,1.03,2860000
"T-Mobile US","162.19","162.64","160.28",1.26,0.78,4280000
"Quanta Services","210.03","211.00","206.43",2.26,1.09,656270
"Leidos","113.52","114.23","112.99",0.25,0.22,621380
"TE Connectivity","144.05","144.45","142.74",0.94,0.66,821920
"Mid-America Apartment","124.53","124.95","123.37",0.05,0.04,1060000
"Charles River Laboratories","222.21","224.61","217.13",3.44,1.57,522620
"Huntington Ingalls Industries","274.05","274.32","271.02",1.92,0.71,188460
"Mettler-Toledo","1175.31","1233.99","1169.94",-49.66,-4.05,289160
"Federal Realty","101.14","102.03","99.94",-0.92,-0.9,855900
"Live Nation Entertainment","89.54","90.50","87.93",1.54,1.75,3140000
"Martin Marietta Materials","527.15","527.15","522.10",1.74,0.33,274340
"FactSet Research","477.65","477.65","470.01",7.59,1.61,153500
"Raymond James Financial","114.90","114.96","112.04",2.8,2.5,927470
"Bio-Rad Labs","325.31","327.48","322.62",-0.8,-0.25,99820
"Digital","147.23","147.94","145.54",0.17,0.12,1200000
"Essex Property","229.86","230.36","228.46",0.52,0.23,269920
"Fair Isaac","1322.98","1336.39","1317.44",1.54,0.12,94330
"Cooper","376.60","376.95","370.49",2.95,0.79,160050
"Molina Healthcare","388.67","390.00","378.05",7.18,1.88,460830
"Everest","353.74","355.03","348.46",-0.82,-0.23,685340
"Regency Centers","61.32","62.09","60.01",-0.82,-1.32,2270000
"Dollar General","135.20","135.91","134.16",-0.45,-0.33,1850000
"Transdigm","1118.35","1139.98","1116.11",-10.22,-0.91,279970
"Atmos Energy","113.08","113.50","112.59",0.15,0.13,532110
"Brown&Brown","81.00","81.01","79.75",1.05,1.32,991410
"Dominos Pizza Inc","424.98","427.55","424.45",-1.6,-0.38,339630
"HCA","306.41","307.51","305.57",0.95,0.31,753540
"Hubbell","363.13","363.36","357.50",4.92,1.37,266750
"Rollins","43.48","44.12","43.19",-0.24,-0.56,1640000
"STERIS","224.28","224.57","218.97",4.02,1.83,600580
"ResMed","184.65","185.95","182.27",0.9,0.49,729020
"MSCI","592.30","594.24","584.88",6.8,1.16,272690
"NVR","7441.0","7476.6","7364.2",-45.7,-0.61,14930
"Carnival Corp","15.30","15.74","14.96",-0.39,-2.49,45420000
"West Pharmaceutical Services","409.79","412.67","408.30",-1.11,-0.27,269440
"Teledyne Technologies","433.82","434.27","428.00",3.15,0.73,140650
"Tyler Technologies","441.04","442.39","433.11",5.89,1.35,139190
"Targa Resources","87.07","88.63","87.06",-0.83,-0.94,1310000
"Meta Platforms","468.11","473.59","467.46",-1.89,-0.4,17910000
"Alexandria RE","116.20","118.18","114.80",-1.7,-1.44,1260000
"Teleflex","252.04","252.30","248.59",0.72,0.29,148180
"Westinghouse Air Brake","136.68","137.56","136.15",-0.05,-0.04,1300000
"Evergy","49.08","49.27","48.40",0.46,0.95,2230000
"Willis Towers Watson","271.86","272.99","269.98",1.75,0.65,462240
"Caesars","44.50","45.17","44.23",0.25,0.56,2900000
"Enphase","122.47","124.55","117.30",5.59,4.78,5270000
"Aptiv","82.02","82.39","80.85",0.73,0.9,2930000
"Crown Castle","108.40","108.53","106.76",0.9,0.84,2640000
"EPAM Systems","286.27","291.35","286.07",-0.9,-0.31,640890
"Pentair","74.48","74.76","73.88",0.04,0.05,1390000
"Mondelez","73.17","74.46","72.83",-1.59,-2.13,7300000
"Skyworks","105.05","105.58","104.04",0.46,0.44,1500000
"Tractor Supply","235.08","235.19","231.34",3.25,1.4,958700
"Lululemon Athletica","470.24","471.26","458.78",-2.74,-0.58,1110000
"Regeneron Pharma","953.42","957.51","945.85",6.99,0.74,500720
"United Rentals","650.35","653.08","643.93",3.35,0.52,363440
"Kinder Morgan","16.61","16.72","16.53",-0.03,-0.15,7420000
"Albemarle","115.74","116.19","113.13",1.41,1.23,2310000
"Centene","77.32","77.36","75.89",0.82,1.07,1930000
"Phillips 66","145.66","147.78","145.35",-1.16,-0.79,2450000
"Xylem","124.30","124.39","122.06",1.92,1.57,1550000
"UDR","35.37","35.67","35.26",-0.12,-0.34,2340000
"SBA Communications","217.45","218.13","214.00",1.25,0.58,946720
"Gartner","463.63","465.02","456.59",7.45,1.63,334900
"IDEX","228.11","228.28","225.29",2.28,1.01,422760
"Universal Health Services","162.37","162.80","161.27",0.41,0.25,264750
"LKQ","47.94","48.11","47.43",0.4,0.84,904460
"Broadridge","198.98","199.86","198.50",-0.02,-0.01,607820
"Extra Space Storage","143.36","144.18","142.39",-0.11,-0.08,694810
"Camden Property","93.45","94.10","93.25",-0.5,-0.53,615980
"Mohawk Industries","110.06","111.80","101.83",0.46,0.42,1980000
"American Water Works","122.22","122.62","121.07",0.39,0.32,718930
"Alliant Energy","47.93","47.99","47.33",0.45,0.95,1460000
"Celanese","149.00","149.06","147.30",0.1,0.07,425180
"Bunge","88.45","88.66","86.81",1.95,2.25,2580000
"Ametek","168.49","168.60","166.41",2.19,1.32,800310
"WR Berkley","80.46","80.51","79.33",0.58,0.73,891180
"LyondellBasell Industries","95.42","95.42","94.37",0.44,0.46,1380000
"Royal Caribbean Cruises","116.99","121.00","115.58",-3.98,-3.29,4120000
"FleetCor","273.21","275.53","266.43",8.09,3.05,770350
"Packaging America","168.37","168.39","166.21",1.5,0.9,450830
"Arthur J Gallagher","238.81","239.18","236.29",1.78,0.75,440910
"Church&Dwight","98.82","100.08","98.16",-1.12,-1.12,989910
"Generac","126.97","128.39","125.16",-0.17,-0.13,884720
"Global Payments","136.00","137.09","134.91",-0.63,-0.46,1510000
"Realty Income","52.75","53.25","52.28",-0.41,-0.77,5830000
"AO Smith","80.55","80.80","79.98",0.3,0.37,537460
"Arch Capital","83.46","83.52","81.59",0.7,0.85,1090000
"Cboe Global","183.55","185.92","182.72",-0.61,-0.33,574360
"Copart","50.91","51.15","50.65",-0.05,-0.1,3010000
"Old Dominion Freight Line","435.33","437.88","428.67",0.17,0.04,784180
"Synopsys","575.30","582.85","571.92",4.61,0.81,967450
"Trimble","52.51","52.64","51.74",0.43,0.83,1510000
"Ulta Beauty","522.63","524.34","517.16",3.51,0.68,480210
"Verisk","250.61","251.47","248.18",1.35,0.54,691700
"AbbVie","174.04","175.40","173.06",-0.75,-0.43,3400000
"Diamondback","151.74","154.22","151.25",-1.74,-1.13,963550
"Norwegian Cruise Line","16.41","17.54","16.35",-1.09,-6.23,21310000
"Zoetis Inc","197.29","198.22","195.59",1.54,0.79,1860000
"IQVIA Holdings","218.26","222.21","216.18",1.26,0.58,1020000
"ServiceNow Inc","813.27","815.27","802.39",13.86,1.73,847820
"Palo Alto Networks","376.90","380.84","369.00",9.88,2.69,3430000
"CDW Corp","245.23","245.41","241.49",1.27,0.52,870860
"Hilton Worldwide","192.17","194.02","191.68",-2.38,-1.22,1530000
"American Airlines","14.88","15.18","14.71",-0.07,-0.47,26850000
"Allegion PLC","131.91","132.95","130.45",1.39,1.07,760630
"Alphabet C","150.22","150.70","147.43",3,2.04,21350000
"Paycom Soft","190.02","197.53","187.47",-5.73,-2.93,1770000
"Arista Networks","282.30","284.82","278.63",6.41,2.32,2950000
"Synchrony Financial","38.72","39.04","38.27",-0.16,-0.41,2810000
"Catalent Inc","56.73","56.95","56.10",0.18,0.32,3640000
"Citizens Financial Group Inc","31.51","31.69","30.89",0.14,0.45,3170000
"Keysight Technologies","161.53","162.50","161.04",0.35,0.22,639790
"Qorvo Inc","112.32","113.79","112.21",-0.3,-0.27,963360
"Etsy Inc","78.09","78.78","73.56",3.6,4.83,3170000
"WestRock Co","42.49","42.78","42.23",-0.33,-0.77,1680000
"PayPal","58.91","59.22","56.16",2.78,4.95,30500000
"Hewlett Packard","15.48","15.55","15.37",-0.01,-0.1,6130000
"Match Group","35.42","35.95","34.95",0.39,1.11,2910000
"Fortive","82.69","82.72","81.87",0.45,0.55,899160
"Lamb Weston Holdings","100.84","101.17","99.57",-0.04,-0.04,881030
"Invitation Homes","33.06","33.10","32.54",0.37,1.13,2410000
"VICI Properties","29.71","29.73","29.34",0.04,0.15,4090000
"Dayforce","70.69","70.71","68.39",0.71,1.01,1280000
"Moderna","87.41","93.36","86.41",-6.25,-6.67,7110000
"Uber Tech","70.89","72.04","69.69",-0.72,-1.01,20300000
"Fox Corp A","29.79","29.85","28.74",1.01,3.51,5670000
"Fox Corp B","27.45","27.58","26.61",0.83,3.12,1740000
"Corteva","53.59","53.91","52.88",0.35,0.66,2400000
"Amcor PLC","9.10","9.14","8.99",-0.07,-0.71,7140000
"Trane Technologies","275.46","275.49","269.20",5.52,2.04,910240
"Otis Worldwide","91.09","91.09","90.29",0.22,0.24,1700000
"Carrier Global","56.00","56.02","54.61",0.94,1.71,4660000
"Howmet","58.59","59.36","58.56",-0.58,-0.98,2390000
"Airbnb","147.60","148.68","145.18",-2.94,-1.96,4950000
"Constellation Energy","132.17","132.51","130.00",1.45,1.11,827840
"GE HealthCare","81.34","82.75","80.61",-0.72,-0.88,2700000
"Kenvue","19.32","19.61","19.08",-0.01,-0.03,24460000
"Veralto","82.17","83.55","81.99",-0.39,-0.47,1180000
"Linde PLC","419.42","419.83","412.29",5.42,1.31,1310000
1 name last high low absolute_change percent_change vol
2 Boeing 209.16 211.41 207.91 -0.06 -0.03 4310000
3 General Motors 38.56 38.97 38.45 -0.09 -0.23 11720000
4 Chevron 151.02 155.31 150.99 -3.04 -1.97 8040000
5 Citigroup 53.98 54.44 53.53 -0.31 -0.57 11750000
6 Bank of America 33.05 33.25 32.83 -0.07 -0.2 30560000
7 AT&T 16.83 16.88 16.57 0.01 0.06 40000000
8 Caterpillar 316.89 322.33 315.57 -5.11 -1.59 2780000
9 Intel 43.31 43.52 42.40 0.81 1.91 43560000
10 Microsoft 420.55 420.82 415.09 6.44 1.56 21300000
11 Ford Motor 12.68 12.92 12.64 -0.15 -1.17 44810000
12 eBay 42.43 42.69 41.79 0.41 0.98 4340000
13 Walt Disney 108.37 110.14 107.69 -2.17 -1.96 19100000
14 Dow 53.99 54.12 53.56 0.11 0.2 3730000
15 Cisco 50.10 50.26 49.63 0.15 0.31 21620000
16 Deere&Company 381.32 385.30 380.46 -4.51 -1.17 1410000
17 FedEx 242.59 242.82 240.14 0.84 0.35 1720000
18 General Mills 62.34 63.86 62.09 -1.73 -2.69 4070000
19 Corning 32.04 32.05 31.58 0.31 0.98 2760000
20 Goldman Sachs 384.21 386.13 382.58 -0.83 -0.22 1620000
21 JPMorgan 174.96 175.10 173.67 0.16 0.09 4270000
22 Kimberly-Clark 119.81 120.47 119.12 -0.46 -0.38 1570000
23 Kraft Heinz 35.97 36.38 35.80 -0.5 -1.37 8020000
24 Coca-Cola 59.56 59.58 59.03 -0.27 -0.45 12020000
25 McDonald’s 289.41 292.49 288.94 -2.52 -0.86 3140000
26 Eli Lilly 739.51 745.68 733.61 3.83 0.52 2410000
27 Oracle 116.60 117.34 115.75 -0.08 -0.07 4990000
28 Merck&Co 125.43 126.69 125.04 -1.18 -0.93 5890000
29 Motorola 330.98 333.00 323.23 2.63 0.8 1180000
30 3M 92.89 93.30 92.39 -0.31 -0.33 3620000
31 Vertex 422.91 425.96 419.51 -0.15 -0.04 1100000
32 Monster Beverage 55.66 56.57 55.35 -0.83 -1.47 3760000
33 Fifth Third 33.65 33.74 33.02 0.26 0.78 4030000
34 Cintas 618.22 620.32 614.07 1.06 0.17 248630
35 Autodesk 266.68 269.53 262.85 5.17 1.98 1730000
36 Gilead 73.67 74.11 72.81 -0.13 -0.18 10150000
37 Alphabet A 149.00 149.44 146.18 3.09 2.12 26030000
38 Fiserv 144.23 144.34 142.74 0.68 0.47 1730000
39 Adobe 627.21 628.07 615.80 11.35 1.84 1950000
40 Qualcomm 151.00 153.40 148.35 2.82 1.9 11310000
41 Warner Bros Discovery 9.64 9.89 9.57 -0.19 -1.93 22450000
42 Applied Materials 185.84 186.15 178.62 11.95 6.87 9240000
43 Steel Dynamics 124.99 125.66 124.21 0.15 0.12 1120000
44 Cadence Design 311.94 313.11 308.82 4.58 1.49 1300000
45 Microchip 85.44 86.00 84.67 1.09 1.29 4750000
46 Wynn Resorts 105.60 106.89 104.26 -0.51 -0.48 2400000
47 Intuitive Surgical 388.22 389.92 383.05 1.28 0.33 1100000
48 Nasdaq Inc 57.25 57.35 56.65 0.49 0.86 1250000
49 Henry Schein 73.72 75.18 73.43 -1.18 -1.58 1330000
50 Paychex 123.03 123.25 121.84 0.28 0.23 1680000
51 VeriSign 198.38 203.00 197.08 -2.64 -1.31 1470000
52 Apple 188.85 189.99 188.00 0.77 0.41 43180000
53 Fastenal 70.02 70.06 69.16 0.76 1.1 2610000
54 Dentsply 32.79 33.16 32.73 -0.37 -1.12 3230000
55 Zions 40.09 40.41 39.45 0.2 0.5 2070000
56 Northern Trust 79.64 79.73 78.25 1.17 1.49 1120000
57 CH Robinson 74.67 74.85 73.59 0.01 0.01 775730
58 PACCAR 106.01 106.08 104.96 0.4 0.38 1400000
59 Amazon.com 174.45 175.00 170.58 4.61 2.71 52940000
60 Ross Stores 145.87 146.42 145.20 -0.19 -0.13 1260000
61 NetApp 89.85 90.33 88.78 0.93 1.05 1360000
62 Garmin 123.21 123.70 121.90 0.89 0.73 580350
63 Costco 723.40 725.53 720.12 -0.76 -0.1 1370000
64 Lam Research 911.58 913.82 874.86 47.22 5.46 1820000
65 Intuit 658.16 662.83 654.69 5.09 0.78 923900
66 Expedia 131.11 132.80 126.05 -28.36 -17.78 18410000
67 Cognizant A 77.10 78.57 76.87 -1.2 -1.53 5250000
68 Akamai 128.32 129.16 127.04 1.54 1.21 1690000
69 KLA Corp 649.80 651.26 628.55 31.31 5.06 1240000
70 Juniper 37.03 37.06 36.93 0.08 0.22 2540000
71 Amgen 291.12 295.00 289.72 -3.73 -1.27 3270000
72 Expeditors Washington 127.39 127.39 125.52 0.21 0.17 837390
73 Electronic Arts 140.61 140.98 137.00 1.76 1.27 1950000
74 T Rowe 106.33 109.83 105.90 -2.58 -2.37 2420000
75 Biogen 240.98 241.36 238.90 0.68 0.28 665370
76 Charles Schwab 63.37 63.41 62.35 0.9 1.44 4530000
77 Huntington Bancshares 12.41 12.48 12.19 0.09 0.73 13750000
78 Gen Digital 21.35 21.39 20.94 0.48 2.3 5700000
79 NVIDIA 721.33 721.85 702.12 24.92 3.58 42810000
80 Starbucks 97.30 97.99 96.31 0.71 0.74 9300000
81 Cincinnati Financial 108.89 109.48 107.12 1.29 1.2 610810
82 Axon Enterprise 270.98 271.39 268.44 1.98 0.74 191750
83 Hologic 73.44 74.14 73.09 0.01 0.01 1460000
84 Comcast 42.07 42.28 41.22 0.82 1.99 26670000
85 Medtronic 84.97 86.15 84.46 -1.21 -1.4 7820000
86 Dover 160.46 160.93 159.36 0.35 0.22 999650
87 Northrop Grumman 454.99 455.56 450.52 3.77 0.84 752290
88 MGM 46.74 46.86 46.21 0.31 0.67 3840000
89 Mastercard 457.74 458.98 456.37 -0.52 -0.11 2050000
90 General Dynamics 270.19 271.36 269.05 1.19 0.44 728860
91 DTE Energy 104.43 104.67 103.14 0.2 0.19 885020
92 Analog Devices 195.02 195.53 193.45 0.96 0.49 3150000
93 VF 15.30 15.50 15.04 -0.05 -0.33 7200000
94 Cardinal Health 104.94 105.42 103.28 2.2 2.15 1990000
95 Xcel Energy 58.17 58.20 57.67 0.17 0.29 2320000
96 DR Horton 144.93 145.31 142.95 0.34 0.24 1400000
97 IPG 30.76 31.71 30.60 -1.04 -3.27 6450000
98 Lockheed Martin 426.42 428.00 424.47 -0.58 -0.14 887810
99 Waters 322.90 327.57 320.82 -3.79 -1.16 397410
100 Accenture 371.63 372.48 367.95 3.15 0.85 1420000
101 Dominion Energy 44.70 44.72 43.92 0.52 1.18 2710000
102 Exxon Mobil 101.75 104.84 101.70 -2.22 -2.14 16690000
103 Cigna 334.85 335.83 330.67 2.56 0.77 1300000
104 Public Service Enterprise 58.41 58.62 57.83 0.21 0.36 1360000
105 NiSource 25.24 25.26 24.96 0.18 0.72 2330000
106 Zimmer Biomet 122.78 125.31 121.19 -0.26 -0.21 3110000
107 CSX 36.89 37.22 36.74 -0.18 -0.49 7940000
108 ICE 135.52 135.69 132.92 1.4 1.04 3850000
109 Southwest Airlines 32.50 32.64 31.80 0.17 0.53 8060000
110 Illinois Tool Works 255.71 255.77 253.63 1.2 0.47 655100
111 Darden Restaurants 167.65 168.34 167.16 -0.53 -0.32 549580
112 Truist Financial Corp 35.83 36.07 34.88 0.32 0.9 9970000
113 Halliburton 34.50 35.15 34.43 -0.53 -1.51 4550000
114 Prologis 132.45 132.77 130.45 0.79 0.6 2780000
115 McCormick&Co 64.64 65.94 64.33 -1.18 -1.79 1390000
116 Host Hotels Resorts 19.58 19.62 19.32 0.03 0.15 4010000
117 Estee Lauder 143.33 145.23 140.61 2.56 1.82 2340000
118 International Paper 35.26 35.32 34.63 0.37 1.06 3130000
119 Emerson 103.19 103.32 102.04 0.81 0.79 3340000
120 Clorox 153.12 155.46 152.44 -1.1 -0.71 753850
121 ConocoPhillips 111.11 114.26 111.02 -2.79 -2.45 5070000
122 Colgate-Palmolive 83.46 84.26 83.18 -0.8 -0.95 3460000
123 Pinnacle West 67.02 67.02 66.18 0.63 0.95 944540
124 Regions Financial 17.99 18.10 17.72 0.1 0.56 9480000
125 CenterPoint Energy 27.53 27.60 27.30 0.1 0.36 2090000
126 MetLife 67.49 67.67 66.40 0.74 1.11 4590000
127 Exelon 33.84 33.90 33.35 0.09 0.27 6090000
128 Baxter 39.55 40.52 38.79 -0.9 -2.24 4580000
129 Occidental 57.46 58.34 57.24 -0.59 -1.02 7450000
130 Southern 66.91 67.20 66.50 -0.03 -0.04 3250000
131 Tapestry 42.01 42.80 41.27 -0.98 -2.28 6310000
132 Lennar 153.03 153.80 151.32 -0.26 -0.17 1370000
133 Campbell Soup 42.00 42.91 41.72 -1.1 -2.55 2910000
134 State Street 72.81 72.98 72.11 0.43 0.59 1270000
135 Progressive 182.52 184.00 182.30 -0.44 -0.24 1710000
136 Vulcan Materials 240.05 240.10 237.40 1.61 0.68 1020000
137 Parker-Hannifin 521.42 521.50 513.99 5.91 1.15 472760
138 Genuine Parts 143.21 143.21 141.43 0.9 0.63 742180
139 CBRE A 86.54 86.64 84.77 0.58 0.67 1230000
140 DuPont De Nemours 67.66 67.72 66.57 0.56 0.83 3220000
141 Sherwin-Williams 311.77 312.99 309.39 -0.38 -0.12 885490
142 Pfizer 27.55 27.59 27.38 -0.01 -0.05 24950000
143 Wells Fargo&Co 48.05 48.35 47.47 -0.3 -0.62 13910000
144 Walmart 169.27 169.73 168.92 -0.1 -0.06 3930000
145 Edison 64.69 64.96 64.34 -0.03 -0.05 1470000
146 Snap-On 262.24 268.14 261.30 -3.79 -1.42 459630
147 Equifax 249.23 253.48 246.07 -2.83 -1.12 828980
148 McKesson 501.35 504.31 494.50 7.12 1.44 1030000
149 Entergy 97.94 98.02 96.80 0.54 0.55 917720
150 CMS Energy 56.06 56.11 55.61 0.31 0.55 1290000
151 Ameriprise Financial 396.45 398.15 392.95 1.62 0.41 235380
152 AIG 69.11 69.23 68.06 0.7 1.03 3020000
153 Ralph Lauren A 175.04 175.40 169.60 3.19 1.86 1730000
154 Bath & Body Works 44.74 44.78 43.79 0.37 0.83 1500000
155 IFF 79.93 80.30 78.72 -0.58 -0.72 3130000
156 WW Grainger 959.38 961.91 946.13 11.31 1.19 194930
157 Constellation Brands A 242.37 244.51 241.65 -0.68 -0.28 1170000
158 American Tower 194.41 194.49 191.64 0.77 0.4 1710000
159 Philip Morris 89.12 89.43 88.55 0.11 0.12 4300000
160 Fidelity National Info 61.78 62.11 61.40 0.34 0.55 2680000
161 Altria 40.10 40.15 39.88 0.01 0.02 8110000
162 Ball 59.16 59.26 58.21 0.02 0.03 1350000
163 Hartford 90.80 90.91 89.31 1.07 1.19 1510000
164 Hershey Co 195.39 201.79 194.68 -6.92 -3.42 2950000
165 Morgan Stanley 85.88 86.03 85.32 0.23 0.27 4620000
166 PNC Financial 147.76 148.38 146.68 -0.17 -0.11 1120000
167 Waste Management 188.87 189.78 187.56 -0.62 -0.33 1570000
168 Cencora Inc 230.69 231.60 228.77 0.37 0.16 1370000
169 Assurant 174.48 175.06 171.86 -0.09 -0.05 220360
170 Kroger 45.41 45.54 45.10 -0.03 -0.07 3120000
171 Molson Coors Brewing B 60.23 60.24 59.37 -0.04 -0.06 2060000
172 Home Depot 363.09 364.43 360.80 -0.63 -0.17 1760000
173 Becton Dickinson 243.66 244.05 240.37 2.95 1.23 2040000
174 JM Smucker 127.90 130.43 127.67 -3.19 -2.43 894090
175 Best Buy 75.58 75.68 74.76 0.09 0.12 1410000
176 Archer-Daniels-Midland 53.07 53.26 52.34 0.37 0.7 4250000
177 Brown Forman 56.59 56.94 56.31 -0.34 -0.6 847500
178 IBM 186.33 187.18 183.86 1.97 1.07 4970000
179 Union Pacific 249.45 249.55 246.51 0.63 0.25 1860000
180 Micron 85.56 85.62 83.96 0.68 0.8 12070000
181 Avery Dennison 204.92 205.15 202.01 1.95 0.96 409860
182 Marathon Oil 22.45 22.87 22.37 -0.25 -1.1 8040000
183 CF Industries 78.08 78.51 76.67 1.57 2.05 1780000
184 APA Corp 29.87 30.69 29.80 -0.6 -1.97 5240000
185 Duke Energy 91.69 92.72 91.63 -0.96 -1.04 3900000
186 KeyCorp 13.88 13.97 13.64 0.01 0.11 13200000
187 Laboratory America 222.58 223.60 222.18 -0.22 -0.1 376300
188 Boston Properties 64.18 65.36 63.60 -0.89 -1.37 1240000
189 Western Digital 56.82 57.35 56.25 -0.3 -0.53 5200000
190 PPG Industries 139.58 139.61 137.46 0.95 0.69 949590
191 S&P Global 438.02 441.04 431.33 1.39 0.32 1770000
192 Williams 34.03 34.26 33.85 -0.01 -0.01 5830000
193 Elevance Health 506.07 506.82 500.18 3.92 0.78 700780
194 Jacobs Engineering 145.54 145.54 143.12 2.37 1.66 737440
195 Eastman Chemical 82.32 82.83 81.90 -0.67 -0.81 622510
196 Verizon 39.72 40.09 39.26 -0.19 -0.49 15090000
197 Nucor 186.52 187.10 185.18 0.23 0.12 1170000
198 Omnicom 84.58 87.09 84.45 -2.05 -2.37 1550000
199 AvalonBay 174.62 174.63 173.20 0.09 0.05 573430
200 Marriott Int 247.02 250.75 245.45 -2.56 -1.03 1370000
201 Ingersoll Rand 85.89 86.51 85.27 0.51 0.6 3670000
202 Bristol-Myers Squibb 49.80 49.83 48.49 1.09 2.24 13400000
203 American Electric Power 76.66 76.73 75.57 0.72 0.95 2010000
204 Thermo Fisher Scientific 550.82 554.13 548.29 -0.07 -0.01 1160000
205 Newmont Goldcorp 32.79 33.22 32.54 -0.55 -1.65 10450000
206 Public Storage 284.04 286.34 280.59 -0.86 -0.3 544300
207 Travelers 214.50 214.99 212.04 0.69 0.32 793950
208 Stanley Black Decker 88.93 89.45 88.56 -0.34 -0.38 778810
209 Franklin Resources 27.09 27.12 26.58 0.26 0.97 2210000
210 Humana 370.16 371.16 366.30 2.36 0.64 1110000
211 Paramount Global B 12.90 13.17 12.84 -0.11 -0.85 9380000
212 Chubb 247.20 247.26 243.32 2.86 1.17 1370000
213 J&J 156.74 157.20 155.71 0.34 0.22 6280000
214 Tyson Foods 52.58 53.83 52.15 -1.39 -2.58 3100000
215 Target 146.51 147.57 146.32 -0.89 -0.6 2750000
216 Jabil Circuit 139.72 140.26 136.05 3.8 2.79 1270000
217 American Express 212.40 214.24 210.42 1.19 0.56 4100000
218 Masco 72.61 74.20 72.17 -0.71 -0.97 3640000
219 Stryker 341.85 344.33 337.63 2.82 0.83 1370000
220 Discover 109.19 109.44 108.03 0.35 0.32 1050000
221 Prudential Financial 105.58 107.65 105.00 -3.03 -2.79 2000000
222 Abbott Labs 111.78 112.63 111.19 -0.65 -0.58 5560000
223 General Electric 139.27 139.42 138.21 0.22 0.16 2900000
224 Quest Diagnostics 126.73 127.60 125.92 0.19 0.15 473100
225 United Parcel Service 146.20 147.83 145.91 -1.72 -1.16 2280000
226 CVS Health Corp 76.32 76.35 74.46 1.24 1.65 7350000
227 PPL 25.87 25.89 25.54 0.21 0.82 5330000
228 Robert Half 81.15 81.62 80.07 0.6 0.74 1010000
229 Simon Property 146.94 147.37 144.06 2.84 1.97 1790000
230 Johnson Controls 55.51 55.72 55.10 0.4 0.72 4240000
231 Cummins 251.84 251.91 248.73 1.92 0.77 572600
232 Allstate 160.06 160.59 158.53 -1.69 -1.04 1280000
233 Sempra Energy 69.67 70.03 69.28 -0.17 -0.24 1220000
234 Devon Energy 41.59 42.58 41.35 -0.82 -1.93 7220000
235 Conagra Brands 27.39 27.99 27.08 -0.7 -2.51 5110000
236 TJX 98.73 99.07 98.01 0.37 0.38 3420000
237 Whirlpool 109.00 110.43 108.64 -1.19 -1.08 669950
238 FirstEnergy 37.31 37.54 36.00 1.52 4.26 9590000
239 Globe Life 125.91 125.98 123.99 0.96 0.77 474680
240 Rtx Corp 90.52 91.61 90.33 -0.52 -0.57 5870000
241 PulteGroup 103.09 103.93 102.15 -0.17 -0.16 1090000
242 Valero Energy 143.08 143.27 141.16 1.28 0.9 3460000
243 Boston Scientific 65.49 65.60 64.85 0.47 0.72 6750000
244 Capital One Financial 135.17 135.50 133.35 0.39 0.29 1330000
245 PG E 16.25 16.39 16.18 0.01 0.03 18190000
246 Norfolk Southern 254.77 256.36 253.69 0.72 0.28 900100
247 Aflac 78.20 78.36 77.56 0.1 0.12 1500000
248 Equity Residential 58.86 59.28 58.65 -0.34 -0.57 1440000
249 Air Products 219.79 219.93 216.50 -0.12 -0.05 2170000
250 Principal Financial 78.20 78.47 76.82 0.44 0.57 828190
251 Texas Instruments 162.40 162.47 160.63 2.19 1.37 3820000
252 HP Inc 28.42 28.52 28.19 0.13 0.46 4110000
253 Honeywell 194.84 195.23 192.83 1.38 0.71 3570000
254 AMD 172.48 175.10 168.66 3.13 1.85 55790000
255 M&T Bank 133.40 133.51 130.09 1.78 1.35 1000000
256 Mosaic 29.91 30.49 29.82 -0.17 -0.58 5290000
257 Revvity 103.74 104.26 102.86 0.49 0.47 483210
258 Las Vegas Sands 53.62 53.85 53.09 0.01 0.01 3320000
259 Freeport-McMoran 37.31 37.91 37.26 -0.81 -2.12 14230000
260 AutoZone 2680.21 2743.53 2680.00 -51.45 -1.88 195110
261 Sysco 79.57 79.63 79.02 0.25 0.32 2120000
262 Ameren 68.67 68.83 67.59 0.76 1.11 1840000
263 Eaton 277.95 278.58 273.00 4.74 1.73 1530000
264 Salesforce Inc 291.27 295.24 291.07 -0.68 -0.23 3730000
265 Consolidated Edison 89.05 89.14 88.32 0.38 0.43 1400000
266 The AES 16.46 16.48 16.14 0.26 1.6 5390000
267 Textron 87.18 88.00 87.00 -0.41 -0.47 958370
268 U.S. Bancorp 40.18 40.39 39.77 -0.19 -0.47 8700000
269 Comerica 51.34 51.84 50.14 0.27 0.53 1370000
270 Visa A 276.40 277.18 274.09 0.62 0.22 2850000
271 Baker Hughes 29.06 29.46 28.83 -0.26 -0.89 7090000
272 Hess 142.04 147.65 142.02 -4.23 -2.89 4550000
273 Yum! Brands 130.27 130.41 129.32 -0.13 -0.1 1920000
274 Marsh McLennan 197.88 197.92 195.94 1.08 0.55 800330
275 Kellanova 53.48 54.74 53.12 -1.46 -2.65 3240000
276 Kimco Realty 20.10 20.10 19.65 0.08 0.4 6400000
277 Ecolab 202.67 203.62 200.50 -0.22 -0.11 972500
278 EOG Resources 111.05 113.75 110.67 -2.01 -1.78 2470000
279 Aon 312.47 312.55 306.67 5.29 1.72 753950
280 Hasbro 50.59 51.10 50.23 -0.09 -0.18 1320000
281 Bank of NY Mellon 55.21 55.36 54.83 0.1 0.18 2440000
282 Schlumberger 47.07 47.85 46.91 -0.72 -1.51 8170000
283 Walgreens Boots 22.24 22.63 22.16 -0.3 -1.33 8340000
284 Rockwell Automation 283.61 284.58 278.77 4.9 1.76 1490000
285 PepsiCo 167.67 171.39 166.97 -6.18 -3.55 12240000
286 UnitedHealth 518.11 520.39 516.34 -1.98 -0.38 2600000
287 Teradyne 102.31 102.42 98.86 3.84 3.9 1640000
288 Danaher 242.88 247.25 242.88 -2.99 -1.22 2030000
289 Seagate 89.41 89.47 87.36 1.73 1.97 1520000
290 Agilent Technologies 133.38 135.15 132.77 -0.69 -0.51 864180
291 Delta Air Lines 40.51 40.87 39.96 0.15 0.38 7570000
292 Moody’s 404.99 405.32 396.40 6.99 1.76 920590
293 Nike 104.50 104.93 103.33 0.73 0.7 4430000
294 Procter&Gamble 157.40 158.34 156.96 -1.24 -0.78 5860000
295 Weyerhaeuser 33.28 33.28 32.97 0.27 0.82 2300000
296 ADP 249.99 250.99 248.89 -1.09 -0.43 1190000
297 Keurig Dr Pepper 31.15 31.49 30.94 -0.34 -1.08 5120000
298 Lowe’s 222.27 222.31 219.17 1.88 0.85 1070000
299 United Airlines Holdings 42.33 43.03 41.60 0.71 1.71 9110000
300 Netflix 561.32 566.00 558.10 2.79 0.5 3020000
301 News Corp 27.21 27.34 26.88 0.2 0.74 874170
302 Equinix 855.76 856.82 844.90 0.43 0.05 354810
303 Booking 3758.18 3761.75 3663.01 -82.04 -2.14 395700
304 O’Reilly Automotive 1025.82 1041.33 1023.58 4.99 0.49 484720
305 BlackRock 796.68 800.80 792.23 3.48 0.44 426020
306 CME Group 205.09 205.60 203.61 1.09 0.53 1420000
307 Illumina 137.84 147.70 135.30 -5.49 -3.83 3470000
308 JB Hunt 215.58 215.90 211.95 2.2 1.03 495810
309 Loews 72.74 72.75 71.66 0.72 1 475990
310 NextEra Energy 56.56 56.63 55.72 0.27 0.48 7840000
311 First Solar 151.50 153.01 144.00 8.33 5.82 2730000
312 Viatris 11.68 11.69 11.45 0.12 1.04 6170000
313 F5 Networks 186.61 187.21 184.44 1.38 0.74 304830
314 Edwards Lifesciences 85.02 86.68 84.89 -0.78 -0.91 2830000
315 News Corp A 26.04 26.18 25.70 0.2 0.77 3940000
316 Amphenol 105.28 105.41 104.55 0.42 0.4 1250000
317 Berkshire Hathaway B 398.33 398.33 395.85 0.84 0.21 1920000
318 Coterra Energy 24.30 24.52 24.20 -0.2 -0.82 4840000
319 CarMax 74.79 74.86 72.90 1.49 2.03 1560000
320 Chipotle Mexican Grill 2636.06 2659.11 2615.93 15.58 0.59 191430
321 DaVita 109.87 111.27 109.73 -0.95 -0.86 677110
322 EQT 34.32 34.81 34.21 -0.43 -1.24 3190000
323 FMC 51.76 52.41 50.63 -0.28 -0.54 3270000
324 L3Harris Technologies 209.84 210.11 207.87 0.54 0.26 517540
325 Healthpeak Properties 17.35 17.80 17.07 -0.64 -3.56 13020000
326 Welltower 87.64 87.68 86.23 0.67 0.76 1720000
327 Hormel Foods 29.07 29.33 28.97 -0.41 -1.37 3560000
328 Invesco 15.89 15.95 15.68 -0.01 -0.09 2870000
329 Iron Mountain 68.63 68.94 68.18 0.07 0.1 642430
330 Eversource Energy 54.95 55.27 53.88 0.88 1.63 3160000
331 NRG 52.37 52.56 51.98 0.02 0.04 1720000
332 ONEOK 69.03 69.49 68.71 0.07 0.09 2080000
333 Pioneer Natural 227.15 233.37 226.85 -4.61 -1.99 1520000
334 Republic Services 173.49 175.07 172.79 -1.15 -0.66 929990
335 Roper Technologies 550.26 550.77 545.11 4.92 0.9 370800
336 Ventas 45.52 45.68 44.99 0 0 1630000
337 WEC Energy 77.57 77.74 76.95 0.17 0.22 2060000
338 Blackstone 127.68 128.63 125.99 -0.16 -0.13 2640000
339 Marathon Petroleum 169.97 170.69 168.73 0.29 0.17 2130000
340 Broadcom 1283.44 1285.65 1249.08 8.68 0.68 2550000
341 NXP 233.55 233.66 228.32 5.72 2.51 2340000
342 Tesla 193.57 194.12 189.49 4.01 2.12 83610000
343 Take-Two 154.91 158.11 152.23 -14.69 -8.66 6580000
344 Dollar Tree 139.50 140.73 139.17 -1.4 -0.99 2290000
345 Align 296.37 298.54 291.53 1.37 0.46 803120
346 ANSYS 342.28 346.64 341.89 -3.05 -0.88 834790
347 Builders FirstSource 185.38 186.54 182.89 0.13 0.07 1040000
348 Charter Communications 291.15 293.40 285.01 8.55 3.03 2050000
349 CoStar 83.13 83.45 80.51 1.36 1.66 4010000
350 DexCom 120.47 124.99 120.29 -6.58 -5.18 5550000
351 Fortinet 70.44 70.86 68.20 2.59 3.82 7090000
352 IDEXX Labs 572.21 578.35 565.52 -1.4 -0.24 312340
353 Incyte 57.66 57.96 57.09 0.3 0.52 1510000
354 Jack Henry&Associates 175.93 176.10 174.01 1.88 1.08 448190
355 MarketAxesss 223.32 226.60 220.29 -0.87 -0.39 280200
356 Monolithic 752.31 761.50 737.22 15.24 2.07 654900
357 Nordson 263.63 263.94 260.81 1.76 0.67 143780
358 ON Semiconductor 80.80 81.59 79.71 0.09 0.11 6140000
359 PTC 183.10 183.69 181.60 1.56 0.86 527660
360 Insulet 192.44 197.46 191.92 -4.2 -2.14 736300
361 Pool 386.65 391.01 384.42 -0.69 -0.18 211450
362 Bio-Techne 67.95 68.19 66.79 0.05 0.07 883500
363 Zebra 253.09 253.73 248.36 5.97 2.42 324810
364 BorgWarner 31.80 32.00 31.29 0.32 1.03 2860000
365 T-Mobile US 162.19 162.64 160.28 1.26 0.78 4280000
366 Quanta Services 210.03 211.00 206.43 2.26 1.09 656270
367 Leidos 113.52 114.23 112.99 0.25 0.22 621380
368 TE Connectivity 144.05 144.45 142.74 0.94 0.66 821920
369 Mid-America Apartment 124.53 124.95 123.37 0.05 0.04 1060000
370 Charles River Laboratories 222.21 224.61 217.13 3.44 1.57 522620
371 Huntington Ingalls Industries 274.05 274.32 271.02 1.92 0.71 188460
372 Mettler-Toledo 1175.31 1233.99 1169.94 -49.66 -4.05 289160
373 Federal Realty 101.14 102.03 99.94 -0.92 -0.9 855900
374 Live Nation Entertainment 89.54 90.50 87.93 1.54 1.75 3140000
375 Martin Marietta Materials 527.15 527.15 522.10 1.74 0.33 274340
376 FactSet Research 477.65 477.65 470.01 7.59 1.61 153500
377 Raymond James Financial 114.90 114.96 112.04 2.8 2.5 927470
378 Bio-Rad Labs 325.31 327.48 322.62 -0.8 -0.25 99820
379 Digital 147.23 147.94 145.54 0.17 0.12 1200000
380 Essex Property 229.86 230.36 228.46 0.52 0.23 269920
381 Fair Isaac 1322.98 1336.39 1317.44 1.54 0.12 94330
382 Cooper 376.60 376.95 370.49 2.95 0.79 160050
383 Molina Healthcare 388.67 390.00 378.05 7.18 1.88 460830
384 Everest 353.74 355.03 348.46 -0.82 -0.23 685340
385 Regency Centers 61.32 62.09 60.01 -0.82 -1.32 2270000
386 Dollar General 135.20 135.91 134.16 -0.45 -0.33 1850000
387 Transdigm 1118.35 1139.98 1116.11 -10.22 -0.91 279970
388 Atmos Energy 113.08 113.50 112.59 0.15 0.13 532110
389 Brown&Brown 81.00 81.01 79.75 1.05 1.32 991410
390 Domino’s Pizza Inc 424.98 427.55 424.45 -1.6 -0.38 339630
391 HCA 306.41 307.51 305.57 0.95 0.31 753540
392 Hubbell 363.13 363.36 357.50 4.92 1.37 266750
393 Rollins 43.48 44.12 43.19 -0.24 -0.56 1640000
394 STERIS 224.28 224.57 218.97 4.02 1.83 600580
395 ResMed 184.65 185.95 182.27 0.9 0.49 729020
396 MSCI 592.30 594.24 584.88 6.8 1.16 272690
397 NVR 7441.0 7476.6 7364.2 -45.7 -0.61 14930
398 Carnival Corp 15.30 15.74 14.96 -0.39 -2.49 45420000
399 West Pharmaceutical Services 409.79 412.67 408.30 -1.11 -0.27 269440
400 Teledyne Technologies 433.82 434.27 428.00 3.15 0.73 140650
401 Tyler Technologies 441.04 442.39 433.11 5.89 1.35 139190
402 Targa Resources 87.07 88.63 87.06 -0.83 -0.94 1310000
403 Meta Platforms 468.11 473.59 467.46 -1.89 -0.4 17910000
404 Alexandria RE 116.20 118.18 114.80 -1.7 -1.44 1260000
405 Teleflex 252.04 252.30 248.59 0.72 0.29 148180
406 Westinghouse Air Brake 136.68 137.56 136.15 -0.05 -0.04 1300000
407 Evergy 49.08 49.27 48.40 0.46 0.95 2230000
408 Willis Towers Watson 271.86 272.99 269.98 1.75 0.65 462240
409 Caesars 44.50 45.17 44.23 0.25 0.56 2900000
410 Enphase 122.47 124.55 117.30 5.59 4.78 5270000
411 Aptiv 82.02 82.39 80.85 0.73 0.9 2930000
412 Crown Castle 108.40 108.53 106.76 0.9 0.84 2640000
413 EPAM Systems 286.27 291.35 286.07 -0.9 -0.31 640890
414 Pentair 74.48 74.76 73.88 0.04 0.05 1390000
415 Mondelez 73.17 74.46 72.83 -1.59 -2.13 7300000
416 Skyworks 105.05 105.58 104.04 0.46 0.44 1500000
417 Tractor Supply 235.08 235.19 231.34 3.25 1.4 958700
418 Lululemon Athletica 470.24 471.26 458.78 -2.74 -0.58 1110000
419 Regeneron Pharma 953.42 957.51 945.85 6.99 0.74 500720
420 United Rentals 650.35 653.08 643.93 3.35 0.52 363440
421 Kinder Morgan 16.61 16.72 16.53 -0.03 -0.15 7420000
422 Albemarle 115.74 116.19 113.13 1.41 1.23 2310000
423 Centene 77.32 77.36 75.89 0.82 1.07 1930000
424 Phillips 66 145.66 147.78 145.35 -1.16 -0.79 2450000
425 Xylem 124.30 124.39 122.06 1.92 1.57 1550000
426 UDR 35.37 35.67 35.26 -0.12 -0.34 2340000
427 SBA Communications 217.45 218.13 214.00 1.25 0.58 946720
428 Gartner 463.63 465.02 456.59 7.45 1.63 334900
429 IDEX 228.11 228.28 225.29 2.28 1.01 422760
430 Universal Health Services 162.37 162.80 161.27 0.41 0.25 264750
431 LKQ 47.94 48.11 47.43 0.4 0.84 904460
432 Broadridge 198.98 199.86 198.50 -0.02 -0.01 607820
433 Extra Space Storage 143.36 144.18 142.39 -0.11 -0.08 694810
434 Camden Property 93.45 94.10 93.25 -0.5 -0.53 615980
435 Mohawk Industries 110.06 111.80 101.83 0.46 0.42 1980000
436 American Water Works 122.22 122.62 121.07 0.39 0.32 718930
437 Alliant Energy 47.93 47.99 47.33 0.45 0.95 1460000
438 Celanese 149.00 149.06 147.30 0.1 0.07 425180
439 Bunge 88.45 88.66 86.81 1.95 2.25 2580000
440 Ametek 168.49 168.60 166.41 2.19 1.32 800310
441 WR Berkley 80.46 80.51 79.33 0.58 0.73 891180
442 LyondellBasell Industries 95.42 95.42 94.37 0.44 0.46 1380000
443 Royal Caribbean Cruises 116.99 121.00 115.58 -3.98 -3.29 4120000
444 FleetCor 273.21 275.53 266.43 8.09 3.05 770350
445 Packaging America 168.37 168.39 166.21 1.5 0.9 450830
446 Arthur J Gallagher 238.81 239.18 236.29 1.78 0.75 440910
447 Church&Dwight 98.82 100.08 98.16 -1.12 -1.12 989910
448 Generac 126.97 128.39 125.16 -0.17 -0.13 884720
449 Global Payments 136.00 137.09 134.91 -0.63 -0.46 1510000
450 Realty Income 52.75 53.25 52.28 -0.41 -0.77 5830000
451 AO Smith 80.55 80.80 79.98 0.3 0.37 537460
452 Arch Capital 83.46 83.52 81.59 0.7 0.85 1090000
453 Cboe Global 183.55 185.92 182.72 -0.61 -0.33 574360
454 Copart 50.91 51.15 50.65 -0.05 -0.1 3010000
455 Old Dominion Freight Line 435.33 437.88 428.67 0.17 0.04 784180
456 Synopsys 575.30 582.85 571.92 4.61 0.81 967450
457 Trimble 52.51 52.64 51.74 0.43 0.83 1510000
458 Ulta Beauty 522.63 524.34 517.16 3.51 0.68 480210
459 Verisk 250.61 251.47 248.18 1.35 0.54 691700
460 AbbVie 174.04 175.40 173.06 -0.75 -0.43 3400000
461 Diamondback 151.74 154.22 151.25 -1.74 -1.13 963550
462 Norwegian Cruise Line 16.41 17.54 16.35 -1.09 -6.23 21310000
463 Zoetis Inc 197.29 198.22 195.59 1.54 0.79 1860000
464 IQVIA Holdings 218.26 222.21 216.18 1.26 0.58 1020000
465 ServiceNow Inc 813.27 815.27 802.39 13.86 1.73 847820
466 Palo Alto Networks 376.90 380.84 369.00 9.88 2.69 3430000
467 CDW Corp 245.23 245.41 241.49 1.27 0.52 870860
468 Hilton Worldwide 192.17 194.02 191.68 -2.38 -1.22 1530000
469 American Airlines 14.88 15.18 14.71 -0.07 -0.47 26850000
470 Allegion PLC 131.91 132.95 130.45 1.39 1.07 760630
471 Alphabet C 150.22 150.70 147.43 3 2.04 21350000
472 Paycom Soft 190.02 197.53 187.47 -5.73 -2.93 1770000
473 Arista Networks 282.30 284.82 278.63 6.41 2.32 2950000
474 Synchrony Financial 38.72 39.04 38.27 -0.16 -0.41 2810000
475 Catalent Inc 56.73 56.95 56.10 0.18 0.32 3640000
476 Citizens Financial Group Inc 31.51 31.69 30.89 0.14 0.45 3170000
477 Keysight Technologies 161.53 162.50 161.04 0.35 0.22 639790
478 Qorvo Inc 112.32 113.79 112.21 -0.3 -0.27 963360
479 Etsy Inc 78.09 78.78 73.56 3.6 4.83 3170000
480 WestRock Co 42.49 42.78 42.23 -0.33 -0.77 1680000
481 PayPal 58.91 59.22 56.16 2.78 4.95 30500000
482 Hewlett Packard 15.48 15.55 15.37 -0.01 -0.1 6130000
483 Match Group 35.42 35.95 34.95 0.39 1.11 2910000
484 Fortive 82.69 82.72 81.87 0.45 0.55 899160
485 Lamb Weston Holdings 100.84 101.17 99.57 -0.04 -0.04 881030
486 Invitation Homes 33.06 33.10 32.54 0.37 1.13 2410000
487 VICI Properties 29.71 29.73 29.34 0.04 0.15 4090000
488 Dayforce 70.69 70.71 68.39 0.71 1.01 1280000
489 Moderna 87.41 93.36 86.41 -6.25 -6.67 7110000
490 Uber Tech 70.89 72.04 69.69 -0.72 -1.01 20300000
491 Fox Corp A 29.79 29.85 28.74 1.01 3.51 5670000
492 Fox Corp B 27.45 27.58 26.61 0.83 3.12 1740000
493 Corteva 53.59 53.91 52.88 0.35 0.66 2400000
494 Amcor PLC 9.10 9.14 8.99 -0.07 -0.71 7140000
495 Trane Technologies 275.46 275.49 269.20 5.52 2.04 910240
496 Otis Worldwide 91.09 91.09 90.29 0.22 0.24 1700000
497 Carrier Global 56.00 56.02 54.61 0.94 1.71 4660000
498 Howmet 58.59 59.36 58.56 -0.58 -0.98 2390000
499 Airbnb 147.60 148.68 145.18 -2.94 -1.96 4950000
500 Constellation Energy 132.17 132.51 130.00 1.45 1.11 827840
501 GE HealthCare 81.34 82.75 80.61 -0.72 -0.88 2700000
502 Kenvue 19.32 19.61 19.08 -0.01 -0.03 24460000
503 Veralto 82.17 83.55 81.99 -0.39 -0.47 1180000
504 Linde PLC 419.42 419.83 412.29 5.42 1.31 1310000

View File

@ -0,0 +1,45 @@
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::{view, LeptosOptions};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
log::debug!("uri = {uri:?} root = {root} ");
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(
options.to_owned(),
|| view! {"Error! Error! Error!"},
);
handler(req).await.into_response()
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
}

View File

@ -0,0 +1,121 @@
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[cfg(feature = "ssr")]
pub mod fallback;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
// initializes logging using the `log` crate
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
// Provide this two our search components, they'll share a read and write handle to a Vec<StockRow>.
let search_results = create_rw_signal(Vec::<StockRow>::new());
provide_context(search_results);
view! {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a Meilisearch backed Searchbar."/>
<Router>
<main>
<Routes>
<Route path="/" view=||view!{
<SearchBar/>
<SearchResults/>
}/>
</Routes>
</main>
</Router>
}
}
#[derive(Clone, serde::Deserialize, serde::Serialize, Debug)]
pub struct StockRow {
id: u32,
name: String,
last: String,
high: String,
low: String,
absolute_change: f32,
percentage_change: f32,
volume: u64,
}
#[leptos::server]
pub async fn search_query(query: String) -> Result<Vec<StockRow>, ServerFnError> {
use leptos_axum::extract;
// Wow, so ergonomic!
let axum::Extension::<meilisearch_sdk::Client>(client) = extract().await?;
// Meilisearch has great defaults, lots of things are thought of for out of the box utility.
// They limit the result length automatically (to 20), and have user friendly typo corrections and return similar words.
let hits = client
.get_index("stock_prices")
.await
.unwrap()
.search()
.with_query(query.as_str())
.execute::<StockRow>()
.await
.map_err(|err| ServerFnError::new(err.to_string()))?
.hits;
Ok(hits
.into_iter()
.map(|search_result| search_result.result)
.collect())
}
#[component]
pub fn SearchBar() -> impl IntoView {
let write_search_results = expect_context::<RwSignal<Vec<StockRow>>>().write_only();
let search_query = create_server_action::<SearchQuery>();
create_effect(move |_| {
if let Some(value) = search_query.value()() {
match value {
Ok(search_results) => {
write_search_results.set(search_results);
}
Err(err) => {
leptos::logging::log!("{err}")
}
}
}
});
view! {
<div>
<label for="search">Search</label>
<input id="search" on:input=move|e|{
let query = event_target_value(&e);
search_query.dispatch(SearchQuery{query});
}/>
</div>
}
}
#[component]
pub fn SearchResults() -> impl IntoView {
let read_search_results = expect_context::<RwSignal<Vec<StockRow>>>().read_only();
view! {
<ul>
<For
each=read_search_results
key=|row| row.name.clone()
children=move |StockRow{name,last,high,low,absolute_change,percentage_change,volume,..}: StockRow| {
view! {
<li>
{format!("{name}; last: {last}; high: {high}; low: {low}; chg.: {absolute_change}; chg...:{percentage_change}; volume:{volume}")}
</li>
}
}
/>
</ul>
}
}

View File

@ -0,0 +1,83 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{routing::get, Extension, Router};
use leptos::get_configuration;
use leptos_axum::{generate_route_list, LeptosRoutes};
use meilisearch_searchbar::StockRow;
use meilisearch_searchbar::{fallback::file_and_error_handler, *};
// simple_logger is a lightweight alternative to tracing, when you absolutely have to trace, use tracing.
simple_logger::SimpleLogger::new()
.with_level(log::LevelFilter::Debug)
.init()
.unwrap();
let mut rdr = csv::Reader::from_path("data_set.csv").unwrap();
// Our data set doesn't have a good id for the purposes of meilisearch, Name is unique but it's not formatted correctly because it may have spaces.
let documents: Vec<StockRow> = rdr
.records()
.enumerate()
.map(|(i, rec)| {
// There's probably a better way to do this.
let mut record = csv::StringRecord::new();
record.push_field(&i.to_string());
for field in rec.unwrap().iter() {
record.push_field(field);
}
record
.deserialize::<StockRow>(None)
.expect(&format!("{:?}", record))
})
.collect();
// My own check. I know how long I expect it to be, if it's not this length something is wrong.
assert_eq!(documents.len(), 503);
let client = meilisearch_sdk::Client::new(
std::env::var("MEILISEARCH_URL").unwrap(),
std::env::var("MEILISEARCH_API_KEY").ok(),
);
// An index is where the documents are stored.
let task = client
.create_index("stock_prices", Some("id"))
.await
.unwrap();
// Meilisearch may take some time to execute the request so we are going to wait till it's completed
client.wait_for_task(task, None, None).await.unwrap();
let task_2 = client
.get_index("stock_prices")
.await
.unwrap()
.add_documents(&documents, Some("id"))
.await
.unwrap();
client.wait_for_task(task_2, None, None).await.unwrap();
drop(documents);
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
// build our application with a route
let app = Router::new()
.route("/favicon.ico", get(file_and_error_handler))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.layer(Extension(client))
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
println!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}

3
projects/nginx-mpmc/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
*/target
.vscode

View File

@ -0,0 +1,34 @@
# Nginx Multiple Server Multiple Client Example
This example shows how multiple clients can communicate with multiple servers while being shared over a single domain i.e localhost:80 using nginx as a reverse proxy.
### How to run this example
```sh
./run.sh
```
Or
```sh
./run_linux.sh
```
<br>
This will boot up nginx via it's docker image mapped to port 80, and the four servers. App-1, App-2, Shared-Server-1, Shared-Server-2.
<br>
App-1, And App-2 are SSR rendering leptos servers.
<br>
If you go to localhost (you'll get App-1), and localhost/app2 (you'll get app2).
<br>
The two shared servers can be communicated with via actions and local resources, or resources (if using CSR).
<br>
`create_resource` Won't work as expected, when trying to communicate to different servers. It will instead try to run the server function on the server you are serving your server side rendered content from. This will cause errors if your server function relies on state that is not present.
<br>
When you are done with this example, run
```sh
./kill.sh
```
Casting ctrl-c multiple times won't close all the open programs.
## Thoughts, Feedback, Criticism, Comments?
Send me any of the above, I'm @sjud on leptos discord. I'm always looking to improve and make these projects more helpful for the community. So please let me know how I can do that. Thanks!

13
projects/nginx-mpmc/app-1/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
# These are backup files generated by rustfmt
**/*.rs.bk
# node e2e test tools and outputs
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/

View File

@ -0,0 +1,120 @@
[package]
name = "app-1"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1"
leptos_meta = { version = "0.6" }
leptos_router = { version = "0.6" }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs","trace"], optional = true }
wasm-bindgen = "=0.2.89"
thiserror = "1"
tracing = { version = "0.1", optional = true }
http = "1"
axum = {version = "0.7",optional=true}
leptos = "0.6"
leptos_axum = {version = "0.6",optional=true}
tokio = { version = "1", features = ["rt-multi-thread"], optional = true}
shared-server = {path = "../shared-server",default-features = false}
shared-server-2 = {path = "../shared-server-2",default-features = false}
tracing-subscriber = {version="0.3.18",features=["env-filter"]}
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[features]
hydrate = [
"leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
"shared-server/hydrate",
"shared-server-2/hydrate"
]
ssr = [
"shared-server/ssr",
"shared-server-2/ssr",
"dep:axum",
"dep:tokio",
"dep:tower",
"dep:tower-http",
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:tracing",
]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "app-1"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
# we're listening inside of a docker container, so we need to set 0.0.0.0 to let it be accessed from outside the container.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3004
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,86 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Axum Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
```bash
cargo install cargo-leptos
```
Then run
```bash
cargo leptos new --git leptos-rs/start-axum
```
to generate a new project template.
```bash
cd app-1
```
to go to your newly created project.
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
## Running your project
```bash
cargo leptos watch
```
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future
## Compiling for Release
```bash
cargo leptos build --release
```
Will generate your server binary in target/server/release and your site package in target/site
## Testing Your Project
```bash
cargo leptos end-to-end
```
```bash
cargo leptos end-to-end --release
```
Cargo-leptos uses Playwright as the end-to-end test tool.
Tests are located in end2end/tests directory.
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
app-1
site/
```
Set the following environment variables (updating for your project as needed):
```text
LEPTOS_OUTPUT_NAME="app-1"
LEPTOS_SITE_ROOT="site"
LEPTOS_SITE_PKG_DIR="pkg"
LEPTOS_SITE_ADDR="127.0.0.1:3000"
LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,79 @@
use crate::error_template::{AppError, ErrorTemplate};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/app-1.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! {
<ErrorTemplate outside_errors/>
}
.into_view()
}>
<main>
<Routes>
<Route path="" view=HomePage/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
use shared_server::SharedServerFunction;
use shared_server_2::SharedServerFunction2;
// A local resource will wait for the client to load before attempting to initialize.
let hello_1 = create_local_resource(move || (), |_| shared_server::shared_server_function());
// this won't work : let hello_1 = create_resource(move || (), |_| shared_server::shared_server_function());
// A resource is initialized on the rendering server when using SSR.
let hello_1_action = Action::<SharedServerFunction,_>::server();
let hello_2_action = Action::<SharedServerFunction2,_>::server();
let value_1 = create_rw_signal(String::from("waiting for update from shared server."));
let value_2 = create_rw_signal(String::from("waiting for update from shared server 2."));
//let hello_2 = create_resource(move || (), shared_server_2::shared_server_function);
create_effect(move|_|{if let Some(Ok(msg)) = hello_1_action.value().get(){value_1.set(msg)}});
create_effect(move|_|{if let Some(Ok(msg)) = hello_2_action.value().get(){value_2.set(msg)}});
view! {
<h1> App 1</h1>
<div>Suspense</div>
<Suspense fallback=move || view! { <p>"Loading (Suspense Fallback)..."</p> }>
{move || {
hello_1.get().map(|data| match data {
Err(_) => view! { <pre>"Error"</pre> }.into_view(),
Ok(hello) => hello.into_view(),
})
}
}
</Suspense>
<div> action response from server 1 </div>
<button on:click=move|_|hello_1_action.dispatch(SharedServerFunction{})>request from shared server 1</button>
{move || value_1.get()}
<div> action response from server 2 </div>
<button on:click=move|_|hello_2_action.dispatch(SharedServerFunction2{})>request from shared server 2</button>
{move || value_2.get()}
}
}

View File

@ -0,0 +1,72 @@
use http::status::StatusCode;
use leptos::*;
use thiserror::Error;
#[derive(Clone, Debug, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
}
}
}
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying the error.
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
let errors = errors.get_untracked();
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
#[cfg(feature = "ssr")]
{
use leptos_axum::ResponseOptions;
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}
view! {
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
children=move |error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@ -0,0 +1,43 @@
use axum::{
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::*;
use crate::app::App;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
tracing::debug!("APP 1");
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

View File

@ -0,0 +1,12 @@
pub mod app;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fileserv;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@ -0,0 +1,49 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use app_1::app::*;
use app_1::fileserv::file_and_error_handler;
use axum::routing::post;
tracing_subscriber::fmt()
.pretty()
.with_thread_names(true)
// enable everything
.with_max_level(tracing::Level::TRACE)
// sets this to be the default, global collector for this application.
.init();
// Setting get_configuration(None) means we'll be using cargo-leptos's env values
// For deployment these variables are:
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
// Alternately a file can be specified such as Some("Cargo.toml")
// The file would need to be included with the executable when moved to deployment
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
// build our application with a route
let app = Router::new()
.route("/api_app1/*fn_name", post(leptos_axum::handle_server_fns))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
logging::log!("listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

13
projects/nginx-mpmc/app-2/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
# These are backup files generated by rustfmt
**/*.rs.bk
# node e2e test tools and outputs
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/

View File

@ -0,0 +1,115 @@
[package]
name = "app-2"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1"
leptos_meta = { version = "0.6" }
leptos_router = { version = "0.6" }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
wasm-bindgen = "=0.2.89"
thiserror = "1"
tracing = { version = "0.1", optional = true }
http = "1"
axum = {version = "0.7",optional=true}
leptos = "0.6"
leptos_axum = {version = "0.6",optional=true}
tokio = { version = "1", features = ["rt-multi-thread"], optional = true}
shared-server = {path = "../shared-server",default-features = false}
shared-server-2 = {path = "../shared-server-2",default-features = false}
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate","shared-server/hydrate","shared-server-2/hydrate"]
ssr = [
"shared-server/ssr",
"shared-server-2/ssr",
"dep:axum",
"dep:tokio",
"dep:tower",
"dep:tower-http",
"dep:leptos_axum",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:tracing",
]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "app-2"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
#
#
### WE CHANGED THIS IN THIS EXAMPLE
#
#
site-pkg-dir = "pkg2"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3001"
# The port to use for automatic reload monitoring
reload-port = 3005
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,86 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Axum Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
```bash
cargo install cargo-leptos
```
Then run
```bash
cargo leptos new --git leptos-rs/start-axum
```
to generate a new project template.
```bash
cd app-2
```
to go to your newly created project.
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
## Running your project
```bash
cargo leptos watch
```
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future
## Compiling for Release
```bash
cargo leptos build --release
```
Will generate your server binary in target/server/release and your site package in target/site
## Testing Your Project
```bash
cargo leptos end-to-end
```
```bash
cargo leptos end-to-end --release
```
Cargo-leptos uses Playwright as the end-to-end test tool.
Tests are located in end2end/tests directory.
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
app-2
site/
```
Set the following environment variables (updating for your project as needed):
```text
LEPTOS_OUTPUT_NAME="app-2"
LEPTOS_SITE_ROOT="site"
LEPTOS_SITE_PKG_DIR="pkg"
LEPTOS_SITE_ADDR="127.0.0.1:3000"
LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,65 @@
use crate::error_template::{AppError, ErrorTemplate};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg2/app-2.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! {
<ErrorTemplate outside_errors/>
}
.into_view()
}>
<main>
<Routes>
<Route path="app2" view=HomePage/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
use shared_server::SharedServerFunction;
use shared_server_2::SharedServerFunction2;
let hello_1_action = Action::<SharedServerFunction,_>::server();
let hello_2_action = Action::<SharedServerFunction2,_>::server();
let value_1 = create_rw_signal(String::from("waiting for update from shared server."));
let value_2 = create_rw_signal(String::from("waiting for update from shared server 2."));
//let hello_2 = create_resource(move || (), shared_server_2::shared_server_function);
create_effect(move|_|{if let Some(Ok(msg)) = hello_1_action.value().get(){value_1.set(msg)}});
create_effect(move|_|{if let Some(Ok(msg)) = hello_2_action.value().get(){value_2.set(msg)}});
view! {
<h1> App 2</h1>
<div> action response from server 1 </div>
<button on:click=move|_|hello_1_action.dispatch(SharedServerFunction{})>request from shared server 1</button>
{move || value_1.get()}
<div> action response from server 2 </div>
<button on:click=move|_|hello_2_action.dispatch(SharedServerFunction2{})>request from shared server 2</button>
{move || value_2.get()}
}
}

View File

@ -0,0 +1,72 @@
use http::status::StatusCode;
use leptos::*;
use thiserror::Error;
#[derive(Clone, Debug, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
}
}
}
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying the error.
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
let errors = errors.get_untracked();
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
#[cfg(feature = "ssr")]
{
use leptos_axum::ResponseOptions;
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors[0].status_code());
}
}
view! {
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
children=move |error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@ -0,0 +1,46 @@
use axum::{
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::*;
use crate::app::App;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
tracing::debug!("APP 2");
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

View File

@ -0,0 +1,12 @@
pub mod app;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fileserv;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@ -0,0 +1,41 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{
Router,
routing::get,
};
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use app_2::app::*;
use app_2::fileserv::file_and_error_handler;
// Setting get_configuration(None) means we'll be using cargo-leptos's env values
// For deployment these variables are:
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
// Alternately a file can be specified such as Some("Cargo.toml")
// The file would need to be included with the executable when moved to deployment
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
logging::log!("listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

5
projects/nginx-mpmc/kill.sh Executable file
View File

@ -0,0 +1,5 @@
lsof -ti :3000 | xargs kill && \
lsof -ti :3001 | xargs kill && \
lsof -ti :3002 | xargs kill && \
lsof -ti :3003 | xargs kill && \
lsof -ti :80 | xargs kill

View File

@ -0,0 +1,43 @@
events {
}
http {
# set aliases
upstream app_server {
server host.docker.internal:3000;
}
upstream app_2_server {
server host.docker.internal:3001;
}
upstream shared_server {
server host.docker.internal:3002;
}
upstream shared_server_2 {
server host.docker.internal:3003;
}
server {
listen 80;
#server_name _;
# /app2 will serve the client for app2, and any client can call the api by calling /app2/api
location /app2 {
proxy_pass http://app_2_server;
}
# We need to set app2 to have a different pkg directory, and to forward on that.
location /pkg2 {
proxy_pass http://app_2_server;
}
# /api_shared will call the server functions registered on shared_server
location /api_shared {
proxy_pass http://shared_server;
}
# /api_shared_2 will call the server functions registered on shared_server_2
location /api_shared2 {
proxy_pass http://shared_server_2;
}
# we will by default serve the client for app-1
location / {
proxy_pass http://app_server;
}
}
}

View File

@ -0,0 +1,39 @@
events {
}
http {
# set aliases
upstream app_server {
server 127.0.0.1:3000;
}
upstream app_2_server {
server 127.0.0.1:3001;
}
upstream shared_server {
server 127.0.0.1:3002;
}
upstream shared_server_2 {
server 127.0.0.1:3003;
}
server {
listen 80;
#server_name _;
# /app2 will serve the client for app2, and any client can call the api by calling /app2/api
location /app2 {
proxy_pass http://app_2_server;
}
# /api_shared will call the server functions registered on shared_server
location /api_shared {
proxy_pass http://shared_server;
}
# /api_shared_2 will call the server functions registered on shared_server_2
location /api_shared2 {
proxy_pass http://shared_server_2;
}
# we will by default serve the client for app-1
location / {
proxy_pass http://app_server;
}
}
}

9
projects/nginx-mpmc/run.sh Executable file
View File

@ -0,0 +1,9 @@
# save pwd variable
# append pwd to nginx.conf prefix
# run this command with the new nginx.conf path
(cd app-1 && cargo leptos serve) & \
(cd app-2 && cargo leptos serve) & \
(cd shared-server-1 && cargo run) & \
(cd shared-server-2 && cargo run) & \
( current_dir=$(pwd) && \
docker run --rm -v "$current_dir"/nginx.conf:/etc/nginx/nginx.conf:ro -p 80:80 nginx)

View File

@ -0,0 +1,9 @@
# save pwd variable
# append pwd to nginx.conf prefix
# run this command with the new nginx.conf path
(cd app-1 && cargo leptos serve) & \
(cd app-2 && cargo leptos serve) & \
(cd shared-server-1 && cargo run) & \
(cd shared-server-2 && cargo run) & \
( current_dir=$(pwd) && \
docker run --rm -v "$current_dir"/nginx_linux.conf:/etc/nginx/nginx.conf:ro -p 80:80 --network="host" nginx)

View File

@ -0,0 +1,13 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
# These are backup files generated by rustfmt
**/*.rs.bk
# node e2e test tools and outputs
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/

View File

@ -0,0 +1,31 @@
[package]
name = "shared-server-1"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = {version = "0.7",optional=true}
leptos = "0.6"
leptos_axum = {version = "0.6",optional=true}
tokio = { version = "1", features = ["rt-multi-thread"], optional = true}
tower-http = {version = "0.5", optional = true, features=["trace"]}
tracing = {version = "0.1.40", optional=true}
tracing-subscriber = {version = "0.3.18", optional = true}
[features]
default = ["ssr"]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:axum",
"dep:tokio",
"dep:leptos_axum",
"dep:tracing",
"dep:tracing-subscriber",
"dep:tower-http",
"leptos/ssr",
]
#We don't need cargo leptos options because we're not using cargo leptos.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,86 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Axum Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
```bash
cargo install cargo-leptos
```
Then run
```bash
cargo leptos new --git leptos-rs/start-axum
```
to generate a new project template.
```bash
cd shared-server
```
to go to your newly created project.
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
## Running your project
```bash
cargo leptos watch
```
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future
## Compiling for Release
```bash
cargo leptos build --release
```
Will generate your server binary in target/server/release and your site package in target/site
## Testing Your Project
```bash
cargo leptos end-to-end
```
```bash
cargo leptos end-to-end --release
```
Cargo-leptos uses Playwright as the end-to-end test tool.
Tests are located in end2end/tests directory.
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
shared-server
site/
```
Set the following environment variables (updating for your project as needed):
```text
LEPTOS_OUTPUT_NAME="shared-server"
LEPTOS_SITE_ROOT="site"
LEPTOS_SITE_PKG_DIR="pkg"
LEPTOS_SITE_ADDR="127.0.0.1:3000"
LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,18 @@
use leptos::*;
#[cfg(feature="ssr")]
#[derive(Clone)]
pub struct SharedServerState;
#[tracing::instrument]
#[server(prefix="/api_shared",endpoint="/a")]
pub async fn shared_server_function() -> Result<String,ServerFnError> {
tracing::debug!("SHARED SERVER 1");
let _ : axum::Extension<SharedServerState> = leptos_axum::extract().await?;
Ok("This message is from the shared server.".to_string())
}
//http://127.0.0.1:3002/api/shared/shared_server_function
// No hydrate function on a server function only server.

View File

@ -0,0 +1,35 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use axum::routing::post;
tracing_subscriber::fmt()
.pretty()
.with_thread_names(true)
// enable everything
.with_max_level(tracing::Level::TRACE)
// sets this to be the default, global collector for this application.
.init();
// In production you wouldn't want to use a hardcoded address like this.
let addr = "127.0.0.1:3002";
// build our application with a route
let app = Router::new()
.route("/api_shared/*fn_name", post(leptos_axum::handle_server_fns))
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(axum::Extension(shared_server::SharedServerState));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("shared server listening on http://{}", addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// our server is SSR only, we have no client pair.
// We'll only ever run this with cargo run --features ssr
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

View File

@ -0,0 +1,13 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
# These are backup files generated by rustfmt
**/*.rs.bk
# node e2e test tools and outputs
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/

View File

@ -0,0 +1,31 @@
[package]
name = "shared-server-2"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = {version = "0.7",optional=true}
leptos = "0.6"
leptos_axum = {version = "0.6",optional=true}
tokio = { version = "1", features = ["rt-multi-thread"], optional = true}
tower-http = {version = "0.5", optional = true, features=["trace"]}
tracing = {version = "0.1.40", optional = true}
tracing-subscriber = {version = "0.3.18", optional = true}
[features]
default = ["ssr"]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:axum",
"dep:tokio",
"dep:leptos_axum",
"dep:tracing",
"dep:tracing-subscriber",
"dep:tower-http",
"leptos/ssr",
]
#We don't need cargo leptos options because we're not using cargo leptos.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,86 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Axum Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
```bash
cargo install cargo-leptos
```
Then run
```bash
cargo leptos new --git leptos-rs/start-axum
```
to generate a new project template.
```bash
cd shared-server
```
to go to your newly created project.
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
## Running your project
```bash
cargo leptos watch
```
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future
## Compiling for Release
```bash
cargo leptos build --release
```
Will generate your server binary in target/server/release and your site package in target/site
## Testing Your Project
```bash
cargo leptos end-to-end
```
```bash
cargo leptos end-to-end --release
```
Cargo-leptos uses Playwright as the end-to-end test tool.
Tests are located in end2end/tests directory.
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
shared-server
site/
```
Set the following environment variables (updating for your project as needed):
```text
LEPTOS_OUTPUT_NAME="shared-server"
LEPTOS_SITE_ROOT="site"
LEPTOS_SITE_PKG_DIR="pkg"
LEPTOS_SITE_ADDR="127.0.0.1:3000"
LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,17 @@
use leptos::*;
#[cfg(feature="ssr")]
#[derive(Clone)]
pub struct SharedServerState2;
#[tracing::instrument]
#[server(prefix="/api_shared2",endpoint="/a")]
pub async fn shared_server_function2() -> Result<String,ServerFnError> {
tracing::debug!("SHARED SERVER 2");
let _ : axum::Extension<SharedServerState2> = leptos_axum::extract().await?;
Ok("This message is from the shared server 2.".to_string())
}
//http://127.0.0.1:3002/api/shared/shared_server_function
// No hydrate function on a server function only server.

View File

@ -0,0 +1,26 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use axum::routing::post;
// In production you wouldn't want to use a hardcoded address like this.
let addr = "127.0.0.1:3003";
// build our application with a route
let app = Router::new()
.route("/api_shared2/*fn_name", post(leptos_axum::handle_server_fns))
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(axum::Extension(shared_server_2::SharedServerState2));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("shared server listening on http://{}", addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// our server is SSR only, we have no client pair.
// We'll only ever run this with cargo run --features ssr
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

1
projects/ory-kratos/.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="sqlite:app.db"

30
projects/ory-kratos/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
.vscode
# These are backup files generated by rustfmt
**/*.rs.bk
e2e/target
e2e/chromedriver_screenshot.png
e2e/html
e2e/network_output
e2e/screenshots/*
e2e/console_logs
localhost+2-key.pem
localhost+2.pem
host.docker.internal+3-key.pem
host.docker.internal+3.pem
key.pem
cert.pem
rootCA.pem
app.db
app.db-shm
app.db-wal
.DS_Store
*/.DS_Stre

View File

@ -0,0 +1,113 @@
[workspace]
resolver = "2"
members = ["app", "frontend", "ids", "server","e2e"]
# need to be applied only to wasm build
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
[workspace.dependencies]
leptos = { version = "0.6.9", features = ["nightly"] }
leptos_meta = { version = "0.6.9", features = ["nightly"] }
leptos_router = { version = "0.6.9", features = ["nightly"] }
leptos_axum = { version = "0.6.9" }
leptos-use = {version = "0.10.5"}
axum = "0.7"
axum-server = {version = "0.6", features = ["tls-rustls"]}
axum-extra = { version = "0.9.2", features=["cookie"]}
cfg-if = "1"
console_error_panic_hook = "0.1.7"
console_log = "1"
http = "1"
ids = {path="./ids"}
# this goes to this personal branch because of https://github.com/ory/sdk/issues/325#issuecomment-1960834676
ory-kratos-client = {git="https://github.com/sjud/kratos-client-rust"}
ory-keto-client = {version = "0.11.0-alpha.0"}
reqwest = { version = "0.11.24", features = ["json","cookies"] }
serde = "1.0.197"
serde_json = "1.0.114"
sqlx = {version= "0.7.3", features=["runtime-tokio","sqlite","macros"]}
thiserror = "1"
time = "0.3.34"
tokio = { version = "1.33.0", features = ["full"] }
tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.5", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = {version="0.3.18", features=["env-filter"]}
url = "2.5.0"
uuid = {version = "1.7.0", features=["v4","serde"]}
wasm-bindgen = "0.2.92"
web-sys = {version = "0.3.69", features=["HtmlDocument","HtmlFormElement","FormData"]}
# See https://github.com/akesson/cargo-leptos for documentation of all the parameters.
# A leptos project defines which workspace members
# that are used together frontend (lib) & server (bin)
[[workspace.metadata.leptos]]
# this name is used for the wasm, js and css file names
name = "ory-auth-example"
# the package in the workspace that contains the server binary (binary crate)
bin-package = "server"
# the package in the workspace that contains the frontend wasm binary (library crate)
lib-package = "frontend"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "cargo test --test app_suite"
end2end-dir = "e2e"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = []
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = []
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@ -0,0 +1,106 @@
# Leptos Ory Kratos Integration (With Axum)
This repo used [start-axum-workspace](https://github.com/leptos-rs/start-axum-workspace/) as a base.
## How to run the example.
Run in different terminal windows (for the best result)
```sh
cargo leptos serve
```
```sh
docker compose up
```
```sh
cargo test --test app_suite
```
This will run our server, set up our compose file (MailCrab, Ory Kratos, Ory Ketos) and run the test suite that walks through logging in, registration, verification etc.
The e2e testing uses [chromiumoxide](https://crates.io/crates/chromiumoxide) and does things like monitor network requests, console messages, take screenshots during the flow and produces them when any of our feature tests fail. This can be a helpful starting point in debugging. Currently it just prints the output into files in the e2e directory but it could be modified to pipe them somewhere like a tool to help with the development process.
## High Level Overview
Our project runs a leptos server alongside various Ory Kratos. Kratos provides identification, and we use it when registering users, and credentialing them.
<br>
A normal flow would look something like:<br>
<ul>
<li>
I go to the homepage,I click register
</li>
</li>
I am redirected to the register page, the register page isn't hardcoded but is rendered by parsing the UI data structure given by Ory Kratos. The visible portions correspond to the fields we've set in our ./kratos/email.schema.json schema file, but it includes
hidden fields (i.e a CSRF token to prevent CSRF). This project includes unstyled parsing code for the UI data structure.
</li>
<li>
I sign up with an email and password
</li>
<li>
Our leptos server will intercept the form data and then pass it on to the ory kratos service.
</li>
<li>
Ory Kratos validates those inputs given the validation criteria ./kratos/email.schema.json schema file
</li>
<li>
Ory Kratos then verifies me by sending me an email.
</li>
<li>
In this example we catch the email with an instance of mailcrab (an email server for testing purposes we run in our docker compose)
. You can use mailcrab locally 127.0.0.1:1080
</li>
<li>
I look inside the email, I see a code and a link where I will input the code.
</li>
<li>
I click through and input the code, and I am verified.
</li>
<li>
When I go to the login page, it's rendered based on the same method as the registration page. I.e Kratos sends a UI data structure which is parsed into the UI we show the user.
</li>
<li>
I use my password and email on the login page to login.
</li>
<li>
Again, Our leptos server acts as the inbetween between the client and the Ory Kratos service. There were some pecularities between the CSRF token being set in the headers (which Ory Kratos updates with every step in the flow), SSR, and having the client communicate directly with Ory Kratos which lead me to use this approach where our server is the intermediary between the client and Ory Kratos.
</li>
<li>
Ory Kratos is session based, so after it recieves valid login credentials it creates a session and returns the session token. The session token is passed via cookies with every future request. All this does is establish the identity of the caller, to perform authentication we need a way to establish permissions given an individuals identity and how that relates to the content on the website. In this example I just use tables in the database but this example could be extended to use Ory Ketos, with is to Authorization a Ory Kratos is to Identification.
</li>
</ul>
When given bad input in a field, Ory Kratos issues a new render UI data structure with error messages and we rerender the login page.
## With regards to Ory Oathkeeper And Ory Ketos.
Ory Oathkeeper is a reverse proxy that sits between your server and the client, it takes the session token, looks to see what is being requested in the request and then checks the configuration files of your Ory Services to see if such a thing is allowed. It will communicate with the Ory services on your behalf and then pass on the authorized request to the appropriate location or reject it otherwise.
<br>
Ory Ketos is the authorization part of the Ory suite, Ory Kratos simplies identifies the user (this is often conflated with authorization but authorization is different). Authorization is the process of after having confirmed a user's identity provisioning services based on some permission structure. I.e Role Based Authorization, Document based permissions, etc. Ory Ketos uses a similar configuration file based set up to Ory Kratos.
<br>
Instead of either of those, in this example we use an extractor to extract the session cookie and verify it with our kratos service and then perform our own checks. This is simpler to set up, more inutitive, and thus better for smaller projects. Identification is complicated, and it's nice to have it be modularized for whatever app we are building. This will save a lot of time when building multiple apps. The actual provisioning of services for most apps is much simpler, i.e database lookup tied to identification and some logic checks. Is the user preiumum? How much have they used the API compared to the maximum? Using Ory Kratos can reduce complexity and decrease your time to market, especially over multiple attempts.
<br>
In production you'd have a virtual private server and you'd serve your leptos server behind Nginx, Nginx routes the calls to the Leptos Server and never to our Ory Kratos. Our Rust server handles all the communication between the client and Ory services. This is simpler from an implementation perspective then including Ory Oathkeeper and Ory Ketos. Ory Kratos/Ketos presume all api calls they recieve are valid by default, so it's best not to expose them at all to any traffic from the outside world. And when building our leptos app we'll have a clear idea about when and how these services are being communicated with when our service acts as the intermediary.
## How this project is tested
We use Gherkin feature files to describe the behavior of the application. We use [cucumber](https://docs.rs/cucumber/latest/cucumber/) as our test harness and match the feature files to [chromiumoxide](https://docs.rs/chromiumoxide/latest/chromiumoxide/) code to drive a local chromium application. I'm using e2e testing mostly to confirm that the service provides the value to the user, in this case just authorization testing. And that, that value proposition doesn't break when we change some middleware code that touches everything etc.
<br>
The `ids` crate includes a list of static strings that we'll use in our chromiumoxide lookups and our frontend to make our testing as smooth as possible. There are other ways to do this, such as find by text, which would find the "Sign Up" text and click it etc. So these tests don't assert anything with regards to presentation, just functionality.
## How to use mkcert to get a locally signed certificate (and why)
We need to use https because we are sending cookies with the `Secure;` flag, cookies with the Secure flag can't be used
unless delivered over https. Since we're using chromedriver for e2e testing let's use mkcert to create a cert that will allow
https://127.0.0.1:3000/ to be a valid url.
Install mkcert and then
```sh
mkcert -install localhost 127.0.0.1 ::1
```
Copy your cert.pem, key.pem and rootCA.pem into this crate's root.
## Thoughts, Feedback, Criticism, Comments?
Send me any of the above, I'm @sjud on leptos discord. I'm always looking to improve and make these projects more helpful for the community. So please let me know how I can do that. Thanks!

View File

@ -0,0 +1,40 @@
[package]
name = "app"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
leptos.workspace = true
leptos_meta.workspace = true
leptos_router.workspace = true
leptos_axum = { workspace = true, optional = true }
leptos-use.workspace = true
axum = { workspace = true, optional = true }
axum-extra = { workspace = true, optional = true }
http.workspace = true
cfg-if.workspace = true
thiserror.workspace = true
serde_json.workspace = true
serde.workspace = true
ory-kratos-client.workspace = true
reqwest = { workspace = true, optional = true }
time = {workspace = true, optional = true }
tracing = { workspace = true, optional = true }
url = { workspace = true, optional = true }
uuid = { workspace = true}
ids.workspace = true
wasm-bindgen = { workspace = true, optional = true}
web-sys = { workspace = true}
sqlx = { workspace = true, optional = true}
[features]
default = []
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate","dep:wasm-bindgen"]
ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:sqlx","leptos-use/axum","leptos-use/ssr","dep:time",
"dep:leptos_axum","dep:axum","dep:tracing","dep:reqwest","dep:url","dep:axum-extra"]

View File

@ -0,0 +1,90 @@
use axum::{async_trait, extract::FromRequestParts, RequestPartsExt};
use axum_extra::extract::CookieJar;
use http::request::Parts;
use ory_kratos_client::models::session::Session;
use sqlx::SqlitePool;
use crate::database_calls::UserRow;
#[derive(Clone, Debug, PartialEq)]
pub struct ExtractSession(pub Session);
#[async_trait]
impl<S> FromRequestParts<S> for ExtractSession
where
S: Send + Sync,
{
type Rejection = String;
#[tracing::instrument(err(Debug), skip_all)]
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let cookie_jar = parts.extract::<CookieJar>().await.unwrap();
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow"
.to_string(),
)?;
let session_cookie = cookie_jar
.get("ory_kratos_session")
.ok_or("Ory Kratos Session cookie does not exist.".to_string())?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get("http://127.0.0.1:4433/sessions/whoami")
.header("accept", "application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.header(
"cookie",
format!("{}={}", session_cookie.name(), session_cookie.value()),
)
.send()
.await
.map_err(|err| format!("Error sending resp to whoami err:{:#?}", err).to_string())?;
let session = resp
.json::<Session>()
.await
.map_err(|err| format!("Error getting json from body err:{:#?}", err).to_string())?;
Ok(Self(session))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExtractUserRow(pub UserRow);
#[async_trait]
impl<S> FromRequestParts<S> for ExtractUserRow
where
S: Send + Sync,
{
type Rejection = String;
#[tracing::instrument(err(Debug), skip_all)]
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity_id = parts
.extract::<ExtractSession>()
.await?
.0
.identity
.ok_or("No identity")?
.id;
let pool = parts
.extract::<axum::Extension<SqlitePool>>()
.await
.map_err(|err| format!("{err:#?}"))?
.0;
let user = crate::database_calls::read_user_by_identity_id(&pool, &identity_id)
.await
.map_err(|err| format!("{err:#?}"))?;
Ok(Self(user))
}
}

View File

@ -0,0 +1,79 @@
use super::*;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct KratosError {
code: Option<usize>,
message: Option<String>,
reason: Option<String>,
debug: Option<String>,
}
impl KratosError {
pub fn to_err_msg(self) -> String {
format!(
"{}\n{}\n{}\n{}\n",
self.code
.map(|code| code.to_string())
.unwrap_or("No Code included in error message".to_string()),
self.message
.unwrap_or("No message in Kratos Error".to_string()),
self.reason
.unwrap_or("No reason included in Kratos Error".to_string()),
self.debug
.unwrap_or("No debug included in Kratos Error".to_string())
)
}
}
impl IntoView for KratosError {
fn into_view(self) -> View {
view!{
<div>{self.code.map(|code|code.to_string()).unwrap_or("No Code included in error message".to_string())}</div>
<div>{self.message.unwrap_or("No message in Kratos Error".to_string())}</div>
<div>{self.reason.unwrap_or("No reason included in Kratos Error".to_string())}</div>
<div>{self.debug.unwrap_or("No debug included in Kratos Error".to_string())}</div>
}.into_view()
}
}
#[server]
pub async fn fetch_error(id: String) -> Result<KratosError, ServerFnError> {
use ory_kratos_client::models::flow_error::FlowError;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
//https://www.ory.sh/docs/kratos/self-service/flows/user-facing-errors
let flow_error = client
.get("http://127.0.0.1:4433/self-service/errors")
.query(&[("id", id)])
.send()
.await?
.json::<FlowError>()
.await?;
let error = flow_error.error.ok_or(ServerFnError::new(
"Flow error does not contain an actual error. This is a server error.",
))?;
Ok(serde_json::from_value::<KratosError>(error)?)
}
#[component]
pub fn KratosErrorPage() -> impl IntoView {
let id = move || use_query_map().get().get("id").cloned().unwrap_or_default();
let fetch_error_resource = create_resource(move || id(), |id| fetch_error(id));
view! {
<Suspense fallback=||"Error loading...".into_view()>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move ||
fetch_error_resource.get().map(|resp| match resp {
// kratos error isn't an error type, it's just a ui/data representation of a kratos error.
Ok(kratos_error) => kratos_error.into_view(),
// notice how we don't deconstruct i.e Err(err), this will bounce up to the error boundary
server_error => server_error.into_view()
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,129 @@
use super::*;
use ory_kratos_client::models::ui_node_attributes::UiNodeAttributes;
use ory_kratos_client::models::ui_node_attributes::UiNodeAttributesTypeEnum;
use ory_kratos_client::models::UiNode;
use ory_kratos_client::models::UiText;
use std::collections::HashMap;
/// https://www.ory.sh/docs/kratos/concepts/ui-user-interface
pub fn kratos_html(node: UiNode, body: RwSignal<HashMap<String, String>>) -> impl IntoView {
// the label that goes as the child of our label
let label_text = node.meta.label.map(|text| text.text);
// each node MAY have messages (i.e password is bad, email is wrong form etc)
let messages_html = view! {
<For
// a function that returns the items we're iterating over; a signal is fine
each=move || node.messages.clone()
// a unique key for each item
key=|ui_text| ui_text.id
// renders each item to a view
children=move |UiText { text,_type,.. }: UiText| {
// colored red, because we assume _type == error...
view!{<p style="color:red;">{text}</p>}
}
/>
};
let node_html = match *node.attributes {
UiNodeAttributes::UiNodeInputAttributes {
autocomplete,
disabled,
name,
required,
_type,
value,
// this is often empty for some reason?
label: _label,
..
} => {
let autocomplete =
autocomplete.map_or(String::new(), |t| serde_json::to_string(&t).unwrap());
let label = label_text.unwrap_or(String::from("Unlabeled Input"));
let required = required.unwrap_or_default();
let _type_str = serde_json::to_string(&_type).unwrap();
let name_clone = name.clone();
let name_clone_2 = name.clone();
let value = if let Some(serde_json::Value::String(value)) = value {
value
} else if value.is_none() {
"".to_string()
} else {
match serde_json::to_string(&value) {
Ok(value) => value,
Err(err) => {
leptos::logging::log!("ERROR: not value? {:?}", err);
"".to_string()
}
}
};
if _type == UiNodeAttributesTypeEnum::Submit {
body.update(|map| {
_ = map.insert(name.clone(), value.clone());
});
view! {
// will be something like value="password" name="method"
// or value="oidc" name="method"
<input type="hidden" value=value name=name/>
<input type="submit" value=label/>
}
.into_view()
} else if _type != UiNodeAttributesTypeEnum::Hidden {
let id = ids::match_name_to_id(name.clone());
view! {
<label>
<span>{&label}</span>
<input name=name
id=id
// we use replace here and in autocomplete because serde_json adds double quotes for some reason?
type=_type_str.replace("\"","")
value=move||body.get().get(&name_clone_2).cloned().unwrap_or_default()
autocomplete=autocomplete.replace("\"","")
disabled=disabled
required=required placeholder=label
on:input=move |ev|{
let name = name_clone.clone();
body.update(|map|{_=map.insert(name,event_target_value(&ev));})
}
/>
</label>
}
.into_view()
} else {
body.update(|map| {
_ = map.insert(name.clone(), value.clone());
});
// this expects the identifer to be an email, but it could be telelphone etc so code is extra fragile
view! {<input type="hidden" value=value name=name /> }.into_view()
}
}
UiNodeAttributes::UiNodeAnchorAttributes { href, id, title } => {
let inner = title.text;
view! {<a href=href id=id>{inner}</a>}.into_view()
}
UiNodeAttributes::UiNodeImageAttributes {
height,
id,
src,
width,
} => view! {<img src=src height=height width=width id=id/>}.into_view(),
UiNodeAttributes::UiNodeScriptAttributes { .. } => view! {script not supported}.into_view(),
UiNodeAttributes::UiNodeTextAttributes {
id,
text:
box UiText {
// not sure how to make use of context yet.
context: _context,
// redundant id?
id: _id,
text,
// This could be, info, error, success. i.e context for msg responses on bad input etc
_type,
},
} => view! {<p id=id>{text}</p>}.into_view(),
};
view! {
{node_html}
{messages_html}
}
}

View File

@ -0,0 +1,217 @@
use super::*;
use ory_kratos_client::models::LoginFlow;
use ory_kratos_client::models::UiContainer;
use ory_kratos_client::models::UiText;
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ViewableLoginFlow(LoginFlow);
impl IntoView for ViewableLoginFlow {
fn into_view(self) -> View {
format!("{:?}", self).into_view()
}
}
#[tracing::instrument]
#[server]
pub async fn init_login() -> Result<LoginResponse, ServerFnError> {
let client = reqwest::ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()?;
// Get the csrf_token cookie.
let resp = client
.get("http://127.0.0.1:4433/self-service/login/browser")
.send()
.await?;
let first_cookie = resp
.cookies()
.next()
.ok_or(ServerFnError::new("Expecting a first cookie"))?;
let csrf_token = first_cookie.value();
let location = resp
.headers()
.get("Location")
.ok_or(ServerFnError::new("expecting location in headers"))?
.to_str()?;
// Parses the url and takes first query which will be flow=FLOW_ID and we get FLOW_ID at .1
let location_url = url::Url::parse(location)?;
let id = location_url
.query_pairs()
.next()
.ok_or(ServerFnError::new(
"Expecting query in location header value",
))?
.1;
let set_cookie = resp
.headers()
.get("set-cookie")
.ok_or(ServerFnError::new("expecting set-cookie in headers"))?
.to_str()?;
let flow = client
.get("http://127.0.0.1:4433/self-service/login/flows")
.query(&[("id", id)])
.header("x-csrf-token", csrf_token)
.send()
.await?
.json::<ViewableLoginFlow>()
.await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(set_cookie)?,
);
Ok(LoginResponse::Flow(flow))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum LoginResponse {
Flow(ViewableLoginFlow),
Success,
}
impl IntoView for LoginResponse {
fn into_view(self) -> View {
match self {
Self::Flow(view) => view.into_view(),
_ => ().into_view(),
}
}
}
#[tracing::instrument]
#[server]
pub async fn login(body: HashMap<String, String>) -> Result<LoginResponse, ServerFnError> {
use ory_kratos_client::models::error_browser_location_change_required::ErrorBrowserLocationChangeRequired;
use ory_kratos_client::models::generic_error::GenericError;
use reqwest::StatusCode;
let mut body = body;
let action = body
.remove("action")
.ok_or(ServerFnError::new("Can't find action on body."))?;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = client
.post(&action)
.header("content-type", "application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.body(serde_json::to_string(&body)?)
.send()
.await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
for value in resp.headers().get_all("set-cookie").iter() {
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(value.to_str()?)?,
);
}
if resp.status() == StatusCode::BAD_REQUEST {
Ok(LoginResponse::Flow(resp.json::<ViewableLoginFlow>().await?))
} else if resp.status() == StatusCode::OK {
// ory_kratos_session cookie set above.
Ok(LoginResponse::Success)
} else if resp.status() == StatusCode::GONE {
let err = resp.json::<GenericError>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::UNPROCESSABLE_ENTITY {
let err = resp.json::<ErrorBrowserLocationChangeRequired>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::TEMPORARY_REDIRECT {
let text = format!("{:#?}", resp);
Err(ServerFnError::new(text))
} else {
// this is a status code that isn't covered by the documentation
// https://www.ory.sh/docs/reference/api#tag/frontend/operation/updateLoginFlow
let status_code = resp.status().as_u16();
Err(ServerFnError::new(format!(
"{status_code} is not covered under the ory documentation?"
)))
}
}
#[component]
pub fn LoginPage() -> impl IntoView {
let login = Action::<Login, _>::server();
let login_flow = create_local_resource(|| (), |_| async move { init_login().await });
let login_resp = create_rw_signal(None::<Result<LoginResponse, ServerFnError>>);
// after user tries to login we update the signal resp.
create_effect(move |_| {
if let Some(resp) = login.value().get() {
login_resp.set(Some(resp))
}
});
let login_flow = Signal::derive(move || {
if let Some(resp) = login_resp.get() {
Some(resp)
} else {
login_flow.get()
}
});
let body = create_rw_signal(HashMap::new());
view! {
<Suspense fallback=||view!{Loading Login Details}>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{
move ||
login_flow.get().map(|resp|
match resp {
Ok(resp) => {
match resp {
LoginResponse::Flow(ViewableLoginFlow(LoginFlow{ui:box UiContainer{nodes,action,messages,..},..})) => {
let form_inner_html = nodes.into_iter().map(|node|kratos_html(node,body)).collect_view();
body.update(move|map|{_=map.insert(String::from("action"),action);});
view!{
<form id=ids::LOGIN_FORM_ID
on:submit=move|e|{
e.prevent_default();
e.stop_propagation();
login.dispatch(Login{body:body.get_untracked()});
}>
{form_inner_html}
{messages.map(|messages|{
view!{
<For
each=move || messages.clone().into_iter()
key=|text| text.id
children=move |text: UiText| {
view! {
<p id=text.id>{text.text}</p>
}
}
/>
}
}).unwrap_or_default()}
</form>
}.into_view()
},
LoginResponse::Success => {
view!{<Redirect path="/"/>}.into_view()
}
}
}
err => err.into_view(),
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,93 @@
use super::*;
#[tracing::instrument]
#[server]
pub async fn logout() -> Result<(), ServerFnError> {
use ory_kratos_client::models::logout_flow::LogoutFlow;
use ory_kratos_client::models::ErrorGeneric;
use reqwest::StatusCode;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let ory_kratos_session = cookie_jar
.get("ory_kratos_session")
.ok_or(ServerFnError::new(
"No `ory_kratos_session` cookie found. Logout shouldn't be visible.",
))?;
let client = reqwest::ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()?;
// get logout url
let resp = client
.get("http://127.0.0.1:4433/self-service/logout/browser")
.header(
"cookie",
format!(
"{}={}",ory_kratos_session.name(),ory_kratos_session.value()
),
)
.send()
.await?;
let status = resp.status();
if status == StatusCode::NO_CONTENT || status == StatusCode::OK {
let LogoutFlow {
logout_token,
logout_url,
} = resp.json::<LogoutFlow>().await?;
tracing::error!("token : {logout_token} url : {logout_url}");
let resp = client
.get(logout_url)
.query(&[("token", logout_token), ("return_to", "/".to_string())])
.header("accept","application/json")
.header(
"cookie",
format!(
"{}={}",
ory_kratos_session.name(),
ory_kratos_session.value()
),
)
.send()
.await?;
let status = resp.status();
if status != StatusCode::OK && status != StatusCode::NO_CONTENT{
let error = resp.json::<ErrorGeneric>().await?;
return Err(ServerFnError::new(format!("{error:#?}")));
}
// set cookies to clear on the client.
crate::clear_cookies_inner().await?;
Ok(())
} else {
let location = resp
.headers()
.get("Location")
.ok_or(ServerFnError::new("expecting location in headers"))?
.to_str()?;
// Parses the url and takes first query which will be flow=FLOW_ID and we get FLOW_ID at .1
let location_url = url::Url::parse(location)?;
tracing::debug!("{}", location_url);
let id = location_url
.query_pairs()
.next()
.ok_or(ServerFnError::new(
"Expecting query in location header value",
))?
.1;
let kratos_err = kratos_error::fetch_error(id.to_string()).await?;
//let error = resp.json::<ory_keto_client::models::ErrorGeneric>().await?;
Err(ServerFnError::new(kratos_err.to_err_msg()))
}
}
#[component]
pub fn LogoutButton() -> impl IntoView {
let logout = Action::<Logout, _>::server();
view! {
<button id=ids::LOGOUT_BUTTON_ID on:click=move|_|logout.dispatch(Logout{})>
Logout
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move || logout.value().get().map(|resp|resp.into_view())}
</ErrorBoundary>
</button>
}
}

View File

@ -0,0 +1,25 @@
use super::error_template::ErrorTemplate;
use leptos::*;
use leptos_router::*;
use leptos_meta::*;
pub mod kratos_html;
use kratos_html::kratos_html;
pub mod registration;
pub use registration::RegistrationPage;
pub mod verification;
use serde::{Deserialize, Serialize};
pub use verification::VerificationPage;
pub mod login;
pub use login::LoginPage;
pub mod session;
pub use session::HasSession;
#[cfg(feature = "ssr")]
pub mod extractors;
pub mod kratos_error;
pub use kratos_error::KratosErrorPage;
pub mod logout;
pub use logout::LogoutButton;
pub mod recovery;
pub use recovery::RecoveryPage;
pub mod settings;
pub use settings::SettingsPage;

View File

@ -0,0 +1,227 @@
use std::collections::HashMap;
use super::*;
use ory_kratos_client::models::{
ContinueWith, ContinueWithSettingsUiFlow, ErrorGeneric, RecoveryFlow, UiContainer, UiText,
};
/*
User clicks recover account button and is directed to the initiate recovery page
On the initiate recovery page they are asked for their email
We send an email to them with a recovery code to recover the identity
and a link to the recovery page which will prompt them for the code.
We validate the code
and we then direct them to the settings page for them to change their password.
*/
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ViewableRecoveryFlow(RecoveryFlow);
// Implment IntoView, not because we want to use IntoView - but, just so we can use ErrorBoundary on the error.
impl IntoView for ViewableRecoveryFlow {
fn into_view(self) -> View {
format!("{:?}", self).into_view()
}
}
pub struct ViewableContinueWith(pub Vec<ContinueWith>);
impl IntoView for ViewableContinueWith {
fn into_view(self) -> View {
if let Some(first) = self.0.first() {
match first {
ContinueWith::ContinueWithSetOrySessionToken { ory_session_token } => todo!(),
ContinueWith::ContinueWithRecoveryUi { flow } => todo!(),
ContinueWith::ContinueWithSettingsUi {
flow: box ContinueWithSettingsUiFlow { id },
} => view! {<Redirect path=format!("/settings?flow={id}")/>}.into_view(),
ContinueWith::ContinueWithVerificationUi { flow } => todo!(),
}
} else {
().into_view()
}
}
}
#[tracing::instrument]
#[server]
pub async fn init_recovery_flow() -> Result<ViewableRecoveryFlow, ServerFnError> {
let client = reqwest::ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()?;
// Get the csrf_token cookie.
let resp = client
.get("http://127.0.0.1:4433/self-service/recovery/browser")
.header("accept", "application/json")
.send()
.await?;
let cookie = resp
.headers()
.get("set-cookie")
.ok_or(ServerFnError::new("Expecting a cookie"))?
.to_str()?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(cookie)?,
);
let status = resp.status();
if status == reqwest::StatusCode::OK {
let flow = resp.json::<RecoveryFlow>().await?;
Ok(ViewableRecoveryFlow(flow))
} else if status == reqwest::StatusCode::BAD_REQUEST {
let error = resp.json::<ErrorGeneric>().await?;
Err(ServerFnError::new(format!("{error:#?}")))
} else {
tracing::error!(
" UNHANDLED STATUS: {} \n text: {}",
status,
resp.text().await?
);
Err(ServerFnError::new("Developer made an oopsies."))
}
}
#[tracing::instrument(ret)]
#[server]
pub async fn process_recovery(
body: HashMap<String, String>,
) -> Result<ViewableRecoveryFlow, ServerFnError> {
use ory_kratos_client::models::error_browser_location_change_required::ErrorBrowserLocationChangeRequired;
use ory_kratos_client::models::generic_error::GenericError;
use reqwest::StatusCode;
let mut body = body;
let action = body
.remove("action")
.ok_or(ServerFnError::new("Can't find action on body."))?;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let csrf_token = csrf_cookie.value();
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = client
.post(&action)
.header("x-csrf-token", csrf_token)
.header("content-type", "application/json")
.header("accept", "application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.body(serde_json::to_string(&body)?)
.send()
.await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
for value in resp.headers().get_all("set-cookie").iter() {
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(value.to_str()?)?,
);
}
if resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::OK {
Ok(resp.json::<ViewableRecoveryFlow>().await?)
} else if resp.status() == StatusCode::SEE_OTHER {
let see_response = format!("{resp:#?}");
let resp_text = resp.text().await?;
let err = format!("Developer needs to handle 303 SEE OTHER resp : \n {see_response} \n body: \n {resp_text}");
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::GONE {
let err = resp.json::<GenericError>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::UNPROCESSABLE_ENTITY {
let err = resp.json::<ErrorBrowserLocationChangeRequired>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else {
// this is a status code that isn't covered by the documentation
// https://www.ory.sh/docs/reference/api#tag/frontend/operation/updateRecoveryFlow
let status_code = resp.status().as_u16();
Err(ServerFnError::new(format!(
"{status_code} is not covered under the ory documentation?"
)))
}
}
#[component]
pub fn RecoveryPage() -> impl IntoView {
let recovery_flow = create_local_resource(|| (), |_| init_recovery_flow());
let recovery = Action::<ProcessRecovery, _>::server();
let recovery_resp = create_rw_signal(None::<Result<ViewableRecoveryFlow, ServerFnError>>);
create_effect(move |_| {
if let Some(resp) = recovery.value().get() {
recovery_resp.set(Some(resp))
}
});
let recovery_flow = Signal::derive(move || {
if let Some(resp) = recovery_resp.get() {
Some(resp)
} else {
recovery_flow.get()
}
});
let body = create_rw_signal(HashMap::new());
view! {
<Suspense fallback=||view!{}>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{
move ||
recovery_flow.get().map(|resp|
match resp {
Ok(ViewableRecoveryFlow(RecoveryFlow{
continue_with,
ui:box UiContainer{nodes,action,messages,..},..})) => {
if let Some(continue_with) = continue_with {
return ViewableContinueWith(continue_with).into_view();
}
let form_inner_html = nodes.into_iter().map(|node|kratos_html(node,body)).collect_view();
body.update(move|map|{_=map.insert(String::from("action"),action);});
view!{
<form id=ids::RECOVERY_FORM_ID
on:submit=move|e|{
if body.get().get(&String::from("code")).is_some() {
// if we have a code we need to drop the email which will be stored from earlier.
// if we include the email then ory kratos server will not try to validate the code.
// but instead send another recovery email.
body.update(move|map|{_=map.remove(&String::from("email"));});
}
e.prevent_default();
e.stop_propagation();
recovery.dispatch(ProcessRecovery{body:body.get_untracked()});
}>
{form_inner_html}
{messages.map(|messages|{
view!{
<For
each=move || messages.clone().into_iter()
key=|text| text.id
children=move |text: UiText| {
view! {
<p id=text.id>{text.text}</p>
}
}
/>
}
}).unwrap_or_default()}
</form>
}.into_view()
},
err => err.into_view(),
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,262 @@
use super::kratos_html;
use super::*;
use ory_kratos_client::models::RegistrationFlow;
use ory_kratos_client::models::UiContainer;
use ory_kratos_client::models::UiText;
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use reqwest::StatusCode;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ViewableRegistrationFlow(RegistrationFlow);
impl IntoView for ViewableRegistrationFlow {
fn into_view(self) -> View {
format!("{:?}", self).into_view()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum RegistrationResponse {
Flow(ViewableRegistrationFlow),
Success,
}
impl IntoView for RegistrationResponse {
fn into_view(self) -> View {
match self {
Self::Flow(view) => view.into_view(),
_ => ().into_view(),
}
}
}
#[tracing::instrument]
#[server]
pub async fn init_registration() -> Result<RegistrationResponse, ServerFnError> {
let client = reqwest::ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()?;
// Get the csrf_token cookie.
let resp = client
.get("http://127.0.0.1:4433/self-service/registration/browser")
.send()
.await?;
let first_cookie = resp
.cookies()
.filter(|c| c.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a cookie with csrf_token in name",
))?;
let csrf_token = first_cookie.value();
let location = resp
.headers()
.get("Location")
.ok_or(ServerFnError::new("expecting location in headers"))?
.to_str()?;
// Parses the url and takes first query which will be flow=FLOW_ID and we get FLOW_ID at .1
let location_url = url::Url::parse(location)?;
let id = location_url
.query_pairs()
.next()
.ok_or(ServerFnError::new(
"Expecting query in location header value",
))?
.1;
let set_cookie = resp
.headers()
.get("set-cookie")
.ok_or(ServerFnError::new("expecting set-cookie in headers"))?
.to_str()?;
let resp = client
.get("http://127.0.0.1:4433/self-service/registration/flows")
.query(&[("id", id)])
.header("x-csrf-token", csrf_token)
.send()
.await?;
let flow = resp.json::<ViewableRegistrationFlow>().await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(set_cookie)?,
);
Ok(RegistrationResponse::Flow(flow))
}
#[tracing::instrument(err)]
#[server]
pub async fn register(
body: HashMap<String, String>,
) -> Result<RegistrationResponse, ServerFnError> {
use ory_kratos_client::models::error_browser_location_change_required::ErrorBrowserLocationChangeRequired;
use ory_kratos_client::models::generic_error::GenericError;
use ory_kratos_client::models::successful_native_registration::SuccessfulNativeRegistration;
let pool = leptos_axum::extract::<axum::Extension<sqlx::SqlitePool>>().await?;
let mut body = body;
let action = body
.remove("action")
.ok_or(ServerFnError::new("Can't find action on body."))?;
let email = body
.get("traits.email")
.cloned()
.ok_or(ServerFnError::new("Can't find traits.email on body."))?;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = client
.post(&action)
//.header("content-type", "application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.json(&body)
.send()
.await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
for value in resp.headers().get_all("set-cookie").iter() {
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(value.to_str()?)?,
);
}
if resp.status() == StatusCode::BAD_REQUEST {
Ok(RegistrationResponse::Flow(
resp.json::<ViewableRegistrationFlow>().await?,
))
} else if resp.status() == StatusCode::OK {
// get identity, session, session token
let SuccessfulNativeRegistration { identity, .. } =
resp.json::<SuccessfulNativeRegistration>().await?;
let identity_id = identity.id;
crate::database_calls::create_user(&pool, &identity_id, &email).await?;
//discard all? what about session_token? I guess we aren't allowing logging in after registration without verification..
Ok(RegistrationResponse::Success)
} else if resp.status() == StatusCode::GONE {
let err = resp.json::<GenericError>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::UNPROCESSABLE_ENTITY {
let err = resp.json::<ErrorBrowserLocationChangeRequired>().await?;
let err = format!("{:#?}", err);
Err(ServerFnError::new(err))
} else if resp.status() == StatusCode::TEMPORARY_REDIRECT {
let text = format!("{:#?}", resp);
Err(ServerFnError::new(text))
} else {
// this is a status code that isn't covered by the documentation
// https://www.ory.sh/docs/reference/api#tag/frontend/operation/updateRegistrationFlow
let status_code = resp.status().as_u16();
Err(ServerFnError::new(format!(
"{status_code} is not covered under the ory documentation?"
)))
}
}
#[component]
pub fn RegistrationPage() -> impl IntoView {
let register = Action::<Register, _>::server();
// when we hit the page initiate a flow with kratos and get back data for ui renering.
let registration_flow =
create_local_resource(|| (), |_| async move { init_registration().await });
// Is none if user hasn't submitted data.
let register_resp = create_rw_signal(None::<Result<RegistrationResponse, ServerFnError>>);
// after user tries to register we update the signal resp.
create_effect(move |_| {
if let Some(resp) = register.value().get() {
register_resp.set(Some(resp))
}
});
// Merge our resource and our action results into a single signal.
// if the user hasn't tried to register yet we'll render the initial flow.
// if they have, we'll render the updated flow (including error messages etc).
let registration_flow = Signal::derive(move || {
if let Some(resp) = register_resp.get() {
Some(resp)
} else {
registration_flow.get()
}
});
// this is the body of our registration form, we don't know what the inputs are so it's a stand in for some
// json map of unknown argument length with type of string.
let body = create_rw_signal(HashMap::new());
view! {
// we'll render the fallback when the user hits the page for the first time
<Suspense fallback=||view!{Loading Registration Details}>
// if we get any errors, from either server functions we've merged we'll render them here.
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{
move ||
// this is the resource XOR the results of the register action.
registration_flow.get().map(|resp|{
match resp {
// TODO add Oauth using the flow args (see type docs)
Ok(resp) => {
match resp {
RegistrationResponse::Flow(ViewableRegistrationFlow(RegistrationFlow{ui:box UiContainer{nodes,action,messages,..},..}))
=> {
let form_inner_html = nodes.into_iter().map(|node|kratos_html(node,body)).collect_view();
body.update(move|map|{_=map.insert(String::from("action"),action);});
view!{
<form
on:submit=move|e|{
e.prevent_default();
e.stop_propagation();
register.dispatch(Register{body:body.get_untracked()});
}
id=ids::REGISTRATION_FORM_ID
>
{form_inner_html}
// kratos_html renders messages for each node and these are the messages attached to the entire form.
{messages.map(|messages|{
view!{
<For
each=move || messages.clone().into_iter()
key=|text| text.id
children=move |text: UiText| {
view! {
<p id=text.id>{text.text}</p>
}
}
/>
}
}).unwrap_or_default()}
</form>
}.into_view()
},
RegistrationResponse::Success => {
view!{<div id=ids::VERIFY_EMAIL_DIV_ID>"Check Email for Verification"</div>}.into_view()
}
}
},
err => err.into_view(),
}
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,31 @@
use super::*;
use ory_kratos_client::models::session::Session;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ViewableSession(pub Session);
impl IntoView for ViewableSession {
fn into_view(self) -> View {
format!("{:#?}", self).into_view()
}
}
#[tracing::instrument]
#[server]
pub async fn session_who_am_i() -> Result<ViewableSession, ServerFnError> {
use self::extractors::ExtractSession;
let session = leptos_axum::extract::<ExtractSession>().await?.0;
Ok(ViewableSession(session))
}
#[component]
pub fn HasSession() -> impl IntoView {
let check_session = Action::<SessionWhoAmI, _>::server();
view! {
<button on:click=move|_|check_session.dispatch(SessionWhoAmI{})>
Check Session Status
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move || check_session.value().get().map(|sesh|sesh.into_view()) }
</ErrorBoundary>
</button>
}
}

View File

@ -0,0 +1,335 @@
use std::collections::HashMap;
use super::*;
use ory_kratos_client::models::{SettingsFlow, UiContainer, UiText};
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct ViewableSettingsFlow(SettingsFlow);
impl IntoView for ViewableSettingsFlow {
fn into_view(self) -> View {
format!("{self:#?}").into_view()
}
}
#[tracing::instrument(ret)]
#[server]
pub async fn init_settings_flow(
flow_id: Option<String>,
) -> Result<ViewableSettingsFlow, ServerFnError> {
use reqwest::StatusCode;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let session_cookie = cookie_jar
.iter()
.filter_map(|cookie| {
if cookie.name().contains("ory_kratos_session") {
Some(format!("{}={}", cookie.name(), cookie.value()))
} else {
None
}
})
.next()
.ok_or(ServerFnError::new("Expecting session cookie"))?;
let csrf_token = cookie_jar
.iter()
.filter_map(|cookie| {
if cookie.name().contains("csrf_token") {
Some(format!("{}={}", cookie.name(), cookie.value()))
} else {
None
}
})
.next()
.ok_or(ServerFnError::new("Expecting csrf token cookie."))?;
let client = reqwest::ClientBuilder::new()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
if let Some(flow_id) = flow_id {
// use flow id to get pre-existing session flow
let resp = client
.get("http://127.0.0.1:4433/self-service/settings/flows")
.query(&[("id", flow_id)])
.header("accept", "application/json")
.header("cookie", format!("{}; {}",csrf_token,session_cookie))
.send()
.await?;
/*let cookie = resp
.headers()
.get("set-cookie")
.ok_or(ServerFnError::new("Expecting a cookie"))?
.to_str()?;
tracing::error!("set cookie init {cookie}");
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(cookie)?,
);*/
// expecting 200:settingsflow ok 401,403,404,410:errorGeneric
let status = resp.status();
if status == StatusCode::OK {
let flow = resp.json::<SettingsFlow>().await?;
Ok(ViewableSettingsFlow(flow))
} else if status == StatusCode::UNAUTHORIZED
|| status == StatusCode::FORBIDDEN
|| status == StatusCode::NOT_FOUND
|| status == StatusCode::GONE
{
// 401 should really redirect to login form...
let err = resp
.json::<ory_kratos_client::models::ErrorGeneric>()
.await?;
Err(ServerFnError::new(format!("{err:#?}")))
} else {
tracing::error!("UHHANDLED STATUS : {status}");
Err(ServerFnError::new("This is a helpful error message."))
}
} else {
// create a new flow
let resp = client
.get("http://127.0.0.1:4433/self-service/settings/browser")
.header("accept", "application/json")
.header("cookie", format!("{}; {}",csrf_token,session_cookie))
.send()
.await?;
if resp.headers().get_all("set-cookie").iter().count() == 0 {
tracing::error!("init set set-cookie is empty");
}
let cookie = resp
.headers()
.get("set-cookie")
.ok_or(ServerFnError::new("Expecting a cookie"))?
.to_str()?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(cookie)?,
);
// expecting 200:settingsflow ok 400,401,403:errorGeneric
let status = resp.status();
if status == StatusCode::OK {
let flow = resp.json::<SettingsFlow>().await?;
Ok(ViewableSettingsFlow(flow))
} else if status == StatusCode::BAD_REQUEST
|| status == StatusCode::UNAUTHORIZED
|| status == StatusCode::FORBIDDEN
{
let err = resp
.json::<ory_kratos_client::models::ErrorGeneric>()
.await?;
Err(ServerFnError::new(format!("{err:#?}")))
} else {
tracing::error!("UHHANDLED STATUS : {status}");
Err(ServerFnError::new("This is a helpful error message."))
}
}
}
#[tracing::instrument(ret)]
#[server]
pub async fn update_settings(
flow_id: String,
body: HashMap<String, String>,
) -> Result<ViewableSettingsFlow, ServerFnError> {
use ory_kratos_client::models::{
ErrorBrowserLocationChangeRequired, ErrorGeneric, GenericError,
};
use reqwest::StatusCode;
let session = leptos_axum::extract::<extractors::ExtractSession>().await?.0;
tracing::error!("{session:#?}");
let mut body = body;
let action = body
.remove("action")
.ok_or(ServerFnError::new("Can't find action on body."))?;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let ory_kratos_session = cookie_jar
.get("ory_kratos_session")
.ok_or(ServerFnError::new(
"No `ory_kratos_session` cookie found. Logout shouldn't be visible.",
))?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let req = client
.post(&action)
.header("accept", "application/json")
.header("cookie",format!("{}={}",csrf_cookie.name(),csrf_cookie.value()))
.header("cookie",format!("{}={}",ory_kratos_session.name(),ory_kratos_session.value()))
.json(&body)
.build()?;
tracing::error!("{req:#?}");
let resp = client.execute(req).await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
if resp.headers().get_all("set-cookie").iter().count() == 0 {
tracing::error!("update set-cookie is empty");
}
for value in resp.headers().get_all("set-cookie").iter() {
tracing::error!("update set cookie {value:#?}");
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(value.to_str()?)?,
);
}
// https://www.ory.sh/docs/reference/api#tag/frontend/operation/updateSettingsFlow
// expecting 400,200:settingsflow ok 401,403,404,410:errorGeneric 422:ErrorBrowserLocationChangeRequired
let status = resp.status();
if status == StatusCode::OK || status == StatusCode::BAD_REQUEST {
let flow = resp.json::<SettingsFlow>().await?;
Ok(ViewableSettingsFlow(flow))
} else if status == StatusCode::UNAUTHORIZED
|| status == StatusCode::FORBIDDEN
|| status == StatusCode::NOT_FOUND
|| status == StatusCode::GONE
{
/*
let ErrorGeneric {
error: box GenericError { id, message, .. },
} = resp.json::<ErrorGeneric>().await?;
if let Some(id) = id {
match id.as_str() {
"session_refresh_required" =>
/*
session_refresh_required: The identity requested to change something that needs a privileged session.
Redirect the identity to the login init endpoint with
query parameters ?refresh=true&return_to=<the-current-browser-url>,
or initiate a refresh login flow otherwise.
*/
{}
"security_csrf_violation" =>
/*
Unable to fetch the flow because a CSRF violation occurred.
*/
{}
"session_inactive" =>
/*
No Ory Session was found - sign in a user first.
*/
{}
"security_identity_mismatch" =>
/*
The flow was interrupted with session_refresh_required
but apparently some other identity logged in instead.
or
The requested ?return_to address is not allowed to be used.
Adjust this in the configuration!
?
*/
{}
"browser_location_change_required" =>
/*
Usually sent when an AJAX request indicates that the browser
needs to open a specific URL. Most likely used in Social Sign In flows.
*/
{}
_ => {}
}
}
*/
let err = resp.json::<ErrorGeneric>().await?;
let err = format!("{err:#?}");
Err(ServerFnError::new(err))
} else if status == StatusCode::UNPROCESSABLE_ENTITY {
let body = resp.json::<ErrorBrowserLocationChangeRequired>().await?;
tracing::error!("{body:#?}");
Err(ServerFnError::new("Unprocessable."))
} else {
tracing::error!("UHHANDLED STATUS : {status}");
Err(ServerFnError::new("This is a helpful error message."))
}
}
#[component]
pub fn SettingsPage() -> impl IntoView {
// get flow id from url
// if flow id doesn't exist we create a settings flow
// otherwise we fetch the settings flow with the flow id
// we update the settings page with the ui nodes
// we handle update settings
// if we are not logged in we'll be redirect to a login page
let init_settings_flow_resource = create_local_resource(
// use untracked here because we don't expect the url to change after resource has been fetched.
|| use_query_map().get_untracked().get("flow").cloned(),
|flow_id| init_settings_flow(flow_id),
);
let update_settings_action = Action::<UpdateSettings, _>::server();
let flow = Signal::derive(move || {
if let Some(flow) = update_settings_action.value().get() {
Some(flow)
} else {
init_settings_flow_resource.get()
}
});
let body = create_rw_signal(HashMap::new());
view! {
<Suspense fallback=||"loading settings...".into_view()>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{
move || flow.get().map(|resp|
match resp {
Ok(
ViewableSettingsFlow(SettingsFlow{id,ui:box UiContainer{nodes,action,messages,..},..})
) => {
let form_inner_html = nodes.into_iter().map(|node|kratos_html(node,body)).collect_view();
body.update(move|map|{_=map.insert(String::from("action"),action);});
let id = create_rw_signal(id);
view!{
<form id=ids::SETTINGS_FORM_ID
on:submit=move|e|{
e.prevent_default();
e.stop_propagation();
update_settings_action.dispatch(UpdateSettings{flow_id:id.get_untracked(),body:body.get_untracked()});
}>
{form_inner_html}
{messages.map(|messages|{
view!{
<For
each=move || messages.clone().into_iter()
key=|text| text.id
children=move |text: UiText| {
view! {
<p id=text.id>{text.text}</p>
}
}
/>
}
}).unwrap_or_default()}
</form>
}.into_view()
},
err => err.into_view()
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,162 @@
use std::collections::HashMap;
use super::*;
use ory_kratos_client::models::{UiContainer, UiText, VerificationFlow};
#[cfg(feature = "ssr")]
use tracing::debug;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ViewableVerificationFlow(VerificationFlow);
impl IntoView for ViewableVerificationFlow {
fn into_view(self) -> View {
format!("{:#?}", self.0).into_view()
}
}
// https://{project}.projects.oryapis.com/self-service/verification/flows?id={}
#[tracing::instrument]
#[server]
pub async fn init_verification(
flow_id: String,
) -> Result<Option<ViewableVerificationFlow>, ServerFnError> {
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
// https://www.ory.sh/docs/reference/api#tag/frontend/operation/getVerificationFlow
let resp = client
.get("http://127.0.0.1:4433/self-service/verification/flows")
.query(&[("id", flow_id)])
//.header("x-csrf-token", csrf_token)
//.header("content-type","application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.send()
.await?;
if resp.status().as_u16() == 403 {
debug!("{:#?}", resp.text().await?);
Ok(None)
} else {
let flow = resp.json::<ViewableVerificationFlow>().await?;
Ok(Some(flow))
}
}
// verification flow complete POST
//http://127.0.0.1:4433/self-service/verification
#[tracing::instrument]
#[server]
pub async fn verify(
body: HashMap<String, String>,
) -> Result<Option<ViewableVerificationFlow>, ServerFnError> {
let mut body = body;
let action = body
.remove("action")
.ok_or(ServerFnError::new("Can't find action on body."))?;
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
let csrf_cookie = cookie_jar
.iter()
.filter(|cookie| cookie.name().contains("csrf_token"))
.next()
.ok_or(ServerFnError::new(
"Expecting a csrf_token cookie to already be set if fetching a pre-existing flow",
))?;
let client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = client
.post(&action)
.header("accept", "application/json")
.header(
"cookie",
format!("{}={}", csrf_cookie.name(), csrf_cookie.value()),
)
.json(&body)
.send()
.await?;
let opts = expect_context::<leptos_axum::ResponseOptions>();
opts.insert_header(
axum::http::HeaderName::from_static("cache-control"),
axum::http::HeaderValue::from_str("private, no-cache, no-store, must-revalidate")?,
);
match resp.json::<ViewableVerificationFlow>().await {
Ok(flow) => Ok(Some(flow)),
Err(_err) => Ok(None),
}
}
#[component]
pub fn VerificationPage() -> impl IntoView {
let verify = Action::<Verify, _>::server();
let params_map = use_query_map();
let init_verification = create_local_resource(
move || params_map().get("flow").cloned().unwrap_or_default(),
|flow_id| async move { init_verification(flow_id).await },
);
let verfication_resp =
create_rw_signal(None::<Result<Option<ViewableVerificationFlow>, ServerFnError>>);
create_effect(move |_| {
if let Some(resp) = verify.value().get() {
verfication_resp.set(Some(resp))
}
});
let verification_flow = Signal::derive(move || {
if let Some(flow) = verfication_resp.get() {
Some(flow)
} else {
init_verification.get()
}
});
let body = create_rw_signal(HashMap::new());
view! {
<Suspense fallback=||view!{Loading Verification Details}>
<ErrorBoundary fallback=|errors|format!("ERRORS: {:?}",errors.get_untracked()).into_view()>
{
move ||
verification_flow.get().map(|resp|{
match resp {
Ok(Some(ViewableVerificationFlow(VerificationFlow{ui:box UiContainer{nodes,messages,action,..},..}))) => {
let form_inner_html = nodes.into_iter().map(|node|kratos_html(node,body)).collect_view();
body.update(|map|{_=map.insert(String::from("action"),action);});
view!{
<form on:submit=move|e|{
e.prevent_default();
e.stop_propagation();
verify.dispatch(Verify{body:body.get_untracked()});
}
id=ids::VERIFICATION_FORM_ID
>
{form_inner_html}
{messages.map(|messages|{
view!{
<For
each=move || messages.clone().into_iter()
key=|text| text.id
children=move |text: UiText| {
view! {
<p id=text.id>{text.text}</p>
}
}
/>
}
}).unwrap_or_default()}
</form>
}.into_view()
},
err => err.into_view(),
}
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,279 @@
use leptos::ServerFnError;
use serde::{Deserialize, Serialize};
use sqlx::{sqlite::SqlitePool, FromRow};
// This will just map into ServerFnError when we call it in our serverfunctions with ? error handling
use sqlx::Error;
use crate::posts_page::PostData;
#[tracing::instrument(err)]
pub async fn create_user(
pool: &SqlitePool,
identity_id: &String,
email: &String,
) -> Result<(), Error> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query!(
"INSERT INTO users (user_id,identity_id,email) VALUES (?,?,?)",
id,
identity_id,
email
)
.execute(pool)
.await?;
Ok(())
}
/// Returns the POST ROW
#[tracing::instrument(ret)]
pub async fn create_post(
pool: &SqlitePool,
user_id: &String,
content: &String,
) -> Result<PostData, Error> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query_as!(
PostData,
"INSERT INTO posts (post_id,user_id,content) VALUES (?,?,?) RETURNING *",
id,
user_id,
content
)
.fetch_one(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn edit_post(
pool: &SqlitePool,
post_id: &String,
content: &String,
user_id: &String,
) -> Result<(), Error> {
sqlx::query!(
"
UPDATE posts
SET content = ?
WHERE post_id = ?
AND EXISTS (
SELECT 1
FROM post_permissions
WHERE post_permissions.post_id = posts.post_id
AND post_permissions.user_id = ?
AND post_permissions.write = TRUE
)",
content,
post_id,
user_id
)
.execute(pool)
.await?;
Ok(())
}
#[tracing::instrument(ret)]
pub async fn delete_post(pool: &SqlitePool, post_id: &String) -> Result<(), Error> {
sqlx::query!("DELETE FROM posts where post_id = ?", post_id)
.execute(pool)
.await?;
Ok(())
}
#[tracing::instrument(ret)]
pub async fn list_users(pool: &SqlitePool) -> Result<Vec<UserRow>, Error> {
sqlx::query_as::<_, UserRow>("SELECT user_id, identity_id FROM users")
.fetch_all(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn read_user(pool: &SqlitePool, user_id: &String) -> Result<UserRow, Error> {
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE user_id = ?")
.bind(user_id)
.fetch_one(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn read_user_by_identity_id(
pool: &SqlitePool,
identity_id: &String,
) -> Result<UserRow, Error> {
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE identity_id = ?")
.bind(identity_id)
.fetch_one(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn read_user_by_email(pool: &SqlitePool, email: &String) -> Result<UserRow, Error> {
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?")
.bind(email)
.fetch_one(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn list_posts(pool: &SqlitePool, user_id: &String) -> Result<Vec<PostData>, Error> {
sqlx::query_as::<_, PostData>(
"
SELECT posts.*
FROM posts
JOIN post_permissions ON posts.post_id = post_permissions.post_id
AND post_permissions.user_id = ?
WHERE post_permissions.read = TRUE
",
)
.bind(user_id)
.fetch_all(pool)
.await
}
#[tracing::instrument(ret)]
pub async fn update_post_permission(
pool: &SqlitePool,
post_id: &String,
user_id: &String,
PostPermission {
read,
write,
delete,
}: PostPermission,
) -> Result<(), Error> {
sqlx::query!(
"
INSERT INTO post_permissions (post_id, user_id, read, write, `delete`)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (post_id, user_id) DO UPDATE SET
read = excluded.read,
write = excluded.write,
`delete` = excluded.`delete`;
",
post_id,
user_id,
read,
write,
delete
)
.execute(pool)
.await?;
Ok(())
}
#[tracing::instrument(ret)]
pub async fn create_post_permissions(
pool: &SqlitePool,
post_id: &String,
user_id: &String,
PostPermission {
read,
write,
delete,
}: PostPermission,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO post_permissions (post_id,user_id,read,write,`delete`) VALUES (?,?,?,?,?)",
post_id,
user_id,
read,
write,
delete
)
.execute(pool)
.await?;
Ok(())
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub struct PostPermission {
pub read: bool,
pub write: bool,
pub delete: bool,
}
impl PostPermission {
#[tracing::instrument(ret)]
pub async fn from_db_call(
pool: &SqlitePool,
user_id: &String,
post_id: &String,
) -> Result<Self, Error> {
if let Ok(row) = sqlx::query_as!(
PostPermissionRow,
"SELECT * FROM post_permissions WHERE post_id = ? AND user_id = ?",
post_id,
user_id
)
.fetch_one(pool)
.await
{
Ok(Self::from(row))
} else {
Ok(Self::default())
}
}
pub fn new_full() -> Self {
Self {
read: true,
write: true,
delete: true,
}
}
pub fn is_full(&self) -> Result<(), ServerFnError> {
if &Self::new_full() != self {
Err(ServerFnError::new("Unauthorized, not full permissions. "))
} else {
Ok(())
}
}
pub fn can_read(&self) -> Result<(), ServerFnError> {
if !self.read {
Err(ServerFnError::new("Unauthorized to read"))
} else {
Ok(())
}
}
pub fn can_write(&self) -> Result<(), ServerFnError> {
if !self.write {
Err(ServerFnError::new("Unauthorized to write"))
} else {
Ok(())
}
}
pub fn can_delete(&self) -> Result<(), ServerFnError> {
if !self.delete {
Err(ServerFnError::new("Unauthorized to delete"))
} else {
Ok(())
}
}
}
impl From<PostPermissionRow> for PostPermission {
fn from(value: PostPermissionRow) -> Self {
Self {
read: value.read,
write: value.write,
delete: value.delete,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, FromRow)]
pub struct PostPermissionRow {
pub post_id: String,
pub user_id: String,
pub read: bool,
pub write: bool,
pub delete: bool,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, FromRow)]
pub struct UserRow {
pub user_id: String,
pub identity_id: String,
pub email: String,
}

View File

@ -0,0 +1,83 @@
use cfg_if::cfg_if;
use http::status::StatusCode;
use leptos::*;
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
use thiserror::Error;
#[derive(Clone, Debug, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
}
}
}
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying the error.
#[component]
pub fn ErrorTemplate(
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let basic_err_msg = if let Some(errors_sig) = errors {
format!("{:#?}", errors_sig.get_untracked())
} else {
"No errors produced by signal".to_string()
};
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
let errors = errors.get_untracked();
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
cfg_if! { if #[cfg(feature="ssr")] {
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
if let Some(error) = errors.get(0) {
response.set_status(error.status_code());
} else {
response.set_status(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}}
view! {
<h1>{if errors.len() > 1 { "Errors" } else { "Error" }}</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=move || { errors.clone().into_iter().enumerate() }
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
children=move |error| {
let error_string = error.1.to_string();
let error_code = error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p >"Error: " {error_string}</p>
}
}
/>
<p id=ids::ERROR_ERROR_ID>{basic_err_msg}</p>
}
}

View File

@ -0,0 +1,116 @@
#![feature(box_patterns)]
use crate::error_template::{AppError, ErrorTemplate};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
pub mod auth;
#[cfg(feature = "ssr")]
pub mod database_calls;
pub mod error_template;
use auth::*;
pub mod posts;
pub use posts::*;
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub struct IsLoggedIn(RwSignal<bool>);
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/ory-auth-example.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! { <ErrorTemplate outside_errors/> }.into_view()
}>
<main>
<Routes>
<Route path="" view=HomePage/>
<Route path=ids::REGISTER_ROUTE view=RegistrationPage/>
<Route path=ids::VERIFICATION_ROUTE view=VerificationPage/>
<Route path=ids::LOGIN_ROUTE view=LoginPage/>
<Route path=ids::KRATOS_ERROR_ROUTE view=KratosErrorPage/>
<Route path=ids::RECOVERY_ROUTE view=RecoveryPage/>
<Route path=ids::SETTINGS_ROUTE view=SettingsPage/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
let clear_cookies = Action::<ClearCookies, _>::server();
view! {
<h1>"Welcome to Leptos!"</h1>
<div>
<a href=ids::REGISTER_ROUTE id=ids::REGISTER_BUTTON_ID>Register</a>
</div>
<div>
<a href=ids::LOGIN_ROUTE id=ids::LOGIN_BUTTON_ID>"Login"</a>
</div>
<div>
<LogoutButton/>
</div>
<div>
<button id=ids::CLEAR_COOKIES_BUTTON_ID
on:click=move|_|clear_cookies.dispatch(ClearCookies{})>Clear cookies </button>
</div>
<div>
<HasSession/>
</div>
<div>
<PostPage/>
</div>
<div>
<a href=ids::RECOVERY_ROUTE id=ids::RECOVER_EMAIL_BUTTON_ID>"Recovery Email"</a>
</div>
<div>
<a href=ids::SETTINGS_ROUTE>"Settings"</a>
</div>
}
}
#[cfg(feature = "ssr")]
pub async fn clear_cookies_inner() -> Result<(), ServerFnError> {
let opts = expect_context::<leptos_axum::ResponseOptions>();
let cookie_jar = leptos_axum::extract::<axum_extra::extract::CookieJar>().await?;
for cookie in cookie_jar.iter() {
let mut cookie = cookie.clone();
cookie.set_expires(
time::OffsetDateTime::now_utc()
.checked_sub(time::Duration::hours(24 * 356 * 10))
.unwrap(),
);
cookie.set_max_age(time::Duration::seconds(0));
cookie.set_path("/");
// To clear an http only cookie, one must set an http only cookie.
cookie.set_http_only(true);
cookie.set_secure(true);
let cookie = cookie.to_string();
opts.append_header(
axum::http::HeaderName::from_static("set-cookie"),
axum::http::HeaderValue::from_str(&cookie)?,
);
}
Ok(())
}
#[tracing::instrument(ret)]
#[server]
pub async fn clear_cookies() -> Result<(), ServerFnError> {
clear_cookies_inner().await?;
Ok(())
}

View File

@ -0,0 +1,34 @@
use super::*;
// An user can post a post. Technically all server functions are POST, so this is a Post Post Post.
#[tracing::instrument(ret)]
#[server]
pub async fn post_post(content: String) -> Result<(), ServerFnError> {
use crate::database_calls::{create_post, create_post_permissions, PostPermission};
let pool = leptos_axum::extract::<axum::Extension<sqlx::SqlitePool>>()
.await?
.0;
let user_id = leptos_axum::extract::<crate::auth::extractors::ExtractUserRow>()
.await?
.0
.user_id;
let PostData { post_id, .. } = create_post(&pool, &user_id, &content).await?;
create_post_permissions(&pool, &post_id, &user_id, PostPermission::new_full()).await?;
Ok(())
}
#[component]
pub fn CreatePost() -> impl IntoView {
let post_post = Action::<PostPost, _>::server();
view! {
<ActionForm action=post_post>
<textarea type="text" name="content" id=ids::POST_POST_TEXT_AREA_ID/>
<input type="submit" value="Post Post" id=ids::POST_POST_SUBMIT_ID/>
</ActionForm>
<Suspense fallback=move||view!{}>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move || post_post.value().get()}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,8 @@
use super::*;
mod post;
use post::Post;
pub mod posts_page;
pub use posts_page::PostPage;
mod create_posts;
use crate::posts_page::PostData;
use create_posts::CreatePost;

View File

@ -0,0 +1,106 @@
use self::posts_page::PostData;
use super::*;
// This is the post, contains all other functionality.
#[component]
pub fn Post(post: PostData) -> impl IntoView {
let PostData {
post_id, content, ..
} = post;
view! {
<div>{content}</div>
<AddEditor post_id=post_id.clone()/>
<EditPost post_id=post_id.clone()/>
}
}
// Only the owner can add an an editor.
#[tracing::instrument(ret)]
#[server]
pub async fn server_add_editor(post_id: String, email: String) -> Result<(), ServerFnError> {
use crate::database_calls::{read_user_by_email, update_post_permission, PostPermission};
let pool: sqlx::Pool<sqlx::Sqlite> =
leptos_axum::extract::<axum::Extension<sqlx::SqlitePool>>()
.await?
.0;
let user_id = leptos_axum::extract::<crate::auth::extractors::ExtractUserRow>()
.await?
.0
.user_id;
let caller_permissions = PostPermission::from_db_call(&pool, &user_id, &post_id).await?;
caller_permissions.is_full()?;
// get other id
let user_id = read_user_by_email(&pool, &email).await?.user_id;
// make an idempotent update to the other users permissions;
let mut permissions = PostPermission::from_db_call(&pool, &post_id, &user_id).await?;
permissions.write = true;
permissions.read = true;
update_post_permission(&pool, &post_id, &user_id, permissions).await?;
Ok(())
}
#[component]
pub fn AddEditor(post_id: String) -> impl IntoView {
let add_editor = Action::<ServerAddEditor, _>::server();
view! {
<ActionForm action=add_editor>
<label value="Add Editor Email">
<input type="text" name="email" id=ids::POST_ADD_EDITOR_INPUT_ID/>
<input type="hidden" name="post_id" value=post_id/>
</label>
<input type="submit" id=ids::POST_ADD_EDITOR_SUBMIT_ID/>
</ActionForm>
<Suspense fallback=||view!{}>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move || add_editor.value().get()}
</ErrorBoundary>
</Suspense>
}
}
// Only the owner and editors can edit a post.
#[tracing::instrument(ret)]
#[server]
pub async fn server_edit_post(post_id: String, content: String) -> Result<(), ServerFnError> {
let pool: sqlx::Pool<sqlx::Sqlite> =
leptos_axum::extract::<axum::Extension<sqlx::SqlitePool>>()
.await?
.0;
let user_id = leptos_axum::extract::<crate::auth::extractors::ExtractUserRow>()
.await?
.0
.user_id;
crate::database_calls::edit_post(&pool, &post_id, &content, &user_id).await?;
Ok(())
}
#[component]
pub fn EditPost(post_id: String) -> impl IntoView {
let edit_post = Action::<ServerEditPost, _>::server();
view! {
<ActionForm action=edit_post>
<label value="New Content:">
<textarea name="content" id=ids::POST_EDIT_TEXT_AREA_ID/>
<input type="hidden" name="post_id" value=post_id/>
</label>
<input type="submit" id=ids::POST_EDIT_SUBMIT_ID/>
</ActionForm>
<Suspense fallback=||view!{}>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{ move || edit_post.value().get()}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct PostData {
pub post_id: String,
pub user_id: String,
pub content: String,
}
impl IntoView for PostData {
fn into_view(self) -> View {
view! {<Post post=self/>}
}
}
#[tracing::instrument(ret)]
#[server]
pub async fn get_post_list() -> Result<Vec<PostData>, ServerFnError> {
use crate::database_calls::list_posts;
let pool = leptos_axum::extract::<axum::Extension<sqlx::SqlitePool>>()
.await?
.0;
let user_id = leptos_axum::extract::<crate::auth::extractors::ExtractUserRow>()
.await?
.0
.user_id;
Ok(list_posts(&pool, &user_id).await?)
}
#[component]
pub fn PostPage() -> impl IntoView {
view! {
<PostsList/>
<CreatePost/>
}
}
#[component]
pub fn PostsList() -> impl IntoView {
let list_posts = Action::<GetPostList, _>::server();
view! {
<button on:click=move|_|list_posts.dispatch(GetPostList{}) id=ids::POST_SHOW_LIST_BUTTON_ID>Show List</button>
<Suspense fallback=||"Post list loading...".into_view()>
<ErrorBoundary fallback=|errors|view!{<ErrorTemplate errors/>}>
{
move || list_posts.value().get().map(|resp|
match resp {
Ok(list) => view!{
<For
each=move || list.clone()
key=|_| uuid::Uuid::new_v4()
children=move |post: PostData| {
post.into_view()
}
/>
}.into_view(),
err => err.into_view()
})
}
</ErrorBoundary>
</Suspense>
}
}

View File

@ -0,0 +1,24 @@
version: '3.7'
services:
mailcrab:
image: marlonb/mailcrab:latest
ports:
- "1080:1080"
- "1025:1025"
networks:
- mynetwork
kratos:
image: oryd/kratos:v1.1.0
command: serve --dev --config /etc/config/kratos/kratos.yaml --watch-courier
volumes:
- "./kratos:/etc/config/kratos"
ports:
- "4433:4433"
- "4434:4434"
networks:
- mynetwork
networks:
mynetwork:
driver: bridge

View File

@ -0,0 +1,40 @@
[package]
name = "e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = {version="0.20.2",features=["tracing","macros"]}
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
reqwest = "0.11.25"
tracing = "0.1.40"
chromiumoxide = {version = "0.5.7", default-features = false, features=["tokio-runtime"]}
ids.workspace = true
fake = "2.9.2"
tokio-tungstenite = "0.21.0"
futures-util = "0.3.30"
uuid = {version="1.7.0",features=["serde"]}
once_cell = "1.19.0"
futures = "0.3.30"
[[test]]
name = "app_suite"
harness = false # Allow Cucumber to print output instead of libtest
[features]
#vscode thing to get autocomplete
ssr=[]
[dependencies]
once_cell = "1.19.0"
regex = "1.10.3"
serde.workspace = true

View File

@ -0,0 +1,6 @@
@test
Feature: Test
Scenario:pass_test_pass
Given I pass

View File

@ -0,0 +1,16 @@
@register
Feature: Register
As a user
I want to register
So that I can login and POST CONTENT.
Scenario:register
Given I am on the homepage
And I click register
And I am on the registration page
And I see the registration form
When I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
Then I am on the homepage

View File

@ -0,0 +1,19 @@
@login
Feature: Login
As a user
I want to log in
So that I can get access to authorized content.
Scenario:login
Given I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
When I click login
And I see the login form
And I re-enter valid credentials
Then I am on the homepage
And I am logged in

View File

@ -0,0 +1,18 @@
@logout
Feature: Logout
As a user
I want to log out after registering
So that I can test to see if login after registering works.
Scenario:logout
Given I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
And I click login
And I re-enter valid credentials
When I click logout
Then I am logged out

View File

@ -0,0 +1,23 @@
@recovery
Feature: Recovery
As a user
I want to recovery my email
So that I can test to see if recovery after registering works.
Scenario:recovery
# lol and and and and and and...
Given I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
And I click login
And I re-enter valid credentials
And I click logout
And I click recover email
And I submit valid recovery email
And I check my email for recovery link and code
When I copy the code onto the recovery link page
Then I am on the settings page

View File

@ -0,0 +1,24 @@
@settings
Feature: Settings
As a user
I want to use the settings page
So that I can update my password.
Scenario:recovery_settings
# lol and and and and and and...
Given I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
And I click login
And I re-enter valid credentials
And I click logout
And I click recover email
And I submit valid recovery email
And I check my email for recovery link and code
When I copy the code onto the recovery link page
And I enter recovery credentials
Then I don't see error

View File

@ -0,0 +1,29 @@
@add-post
Feature: Add-post
As a user
I want to add a post
So that I can share my EXAMPLE DATA with the world!
Background:
Given I am on the homepage
And I clear cookies
Scenario: add_post_logged_in
Given I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
And I click login
And I re-enter valid credentials
When I add example post
And I click show post list
Then I see example content posted
Scenario: add_post_logged_out
Given I am logged out
When I add example post
Then I see error

View File

@ -0,0 +1,52 @@
@edit-post
Feature: Edit-Post
As a user
I want to add an editor to my post
So that my bestie can improve my EXAMPLE CONTENT.
Background:
Given I am on the registration page
And I see the registration form
And I enter valid other credentials
And I check my other email for the verification link and code
And I copy the code onto the verification link page
And I am on the registration page
And I see the registration form
And I enter valid credentials
And I check my email for the verification link and code
And I copy the code onto the verification link page
Scenario: add_editor_as_owner_and_edit_post
Given I am on the homepage
And I click login
And I re-enter valid credentials
And I add example post
And I click show post list
And I see example content posted
When I add other email as editor
And I logout
And I click login
And I re-enter other valid credentials
And I click show post list
And I see example content posted
And I edit example post
And I click show post list
Then I see my new content posted
And I don't see old content
Scenario: add_editor_as_other
Given I am on the homepage
And I click login
And I re-enter valid credentials
And I add example post
And I click show post list
And I see example content posted
When I add other email as editor
And I logout
And I click login
And I re-enter other valid credentials
And I click show post list
And I see example content posted
And I add other email as editor
Then I see error

View File

@ -0,0 +1,601 @@
#![feature(never_type)]
mod fixtures;
use anyhow::anyhow;
use anyhow::Result;
use chromiumoxide::cdp::browser_protocol::log::EventEntryAdded;
use chromiumoxide::cdp::js_protocol::runtime::EventConsoleApiCalled;
use chromiumoxide::{
browser::{Browser, BrowserConfig},
cdp::browser_protocol::{
network::{EventRequestWillBeSent, EventResponseReceived, Request, Response},
page::NavigateParams,
},
element::Element,
page::ScreenshotParams,
Page,
};
use cucumber::World;
use futures::channel::mpsc::Sender;
use futures_util::stream::StreamExt;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use tokio_tungstenite::connect_async;
use uuid::Uuid;
static EMAIL_ID_MAP: Lazy<RwLock<HashMap<String, String>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
#[derive(Clone, Debug, PartialEq)]
pub struct RequestPair {
req: Option<Request>,
redirect_resp: Option<Response>,
resp: Option<Response>,
cookies_before_request: String,
cookies_after_response: String,
ts: std::time::Instant,
}
/*
let screenshot = world
.page
.screenshot(
ScreenshotParams::builder()
.capture_beyond_viewport(true)
.full_page(true)
.build(),
)
.await
.unwrap();
world.screenshots.push(screenshot);
*/
#[derive(Clone, Debug)]
pub enum CookieEnum {
BeforeReq(String),
AfterResp(String),
}
impl RequestPair {
pub fn to_string(&self) -> String {
let (top_req, req_headers) = if let Some(req) = &self.req {
(
format!("{} : {} \n", req.method, req.url,),
format!("{} :\n{:#?} \n", req.url, req.headers),
)
} else {
("NO REQ".to_string(), "NO REQ".to_string())
};
let (top_redirect_resp, _redirect_resp_headers) = if let Some(resp) = &self.redirect_resp {
(
format!("{} : {}", resp.status, resp.url),
format!("{} :\n {:#?}", resp.url, resp.headers),
)
} else {
("".to_string(), "".to_string())
};
let (top_resp, resp_headers) = if let Some(resp) = &self.resp {
(
format!("{} : {}", resp.status, resp.url),
format!("{} :\n {:#?}", resp.url, resp.headers),
)
} else {
("NO RESP".to_string(), "NO RESP".to_string())
};
format!(
"REQ: {}\n RESP: {}\n \n REDIRECT {} \n REQ_HEADERS: {} \n REQ_COOKIES: \n{}\n RESP_HEADERS:{} \n RESP_COOKIES: \n{}\n ",
top_req, top_resp,top_redirect_resp, req_headers, self.cookies_before_request,resp_headers,self.cookies_after_response
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// create a thread and store a
// tokio-tungstenite client that connectsto http://127.0.0.1:1080/ws
// and then stores the recieved messages in a once_cell::Lazy<RwLock<Vec<MailCrabMsg>>>
// or a custom struct that matches the body or has specific impls for verify codes, links etc.
let _ = tokio::spawn(async move {
let (mut socket, _) = connect_async(
url::Url::parse("ws://127.0.0.1:1080/ws").expect("Can't connect to case count URL"),
)
.await
.unwrap();
while let Some(msg) = socket.next().await {
if let Ok(tokio_tungstenite::tungstenite::Message::Text(text)) = msg {
let Email { id, to } = serde_json::from_str::<Email>(&text).unwrap();
let email = to[0].email.clone();
EMAIL_ID_MAP.write().await.insert(email, id.to_string());
}
}
});
AppWorld::cucumber()
.init_tracing()
.fail_on_skipped()
.max_concurrent_scenarios(1)
.fail_fast()
.before(|_feature, _rule, scenario, world| {
Box::pin(async move {
let screenshot_directory_name = format!("./screenshots/{}", scenario.name);
if let Ok(sc_dir) = std::fs::read_dir(&screenshot_directory_name) {
for file in sc_dir {
if let Ok(file) = file {
std::fs::remove_file(file.path()).unwrap();
}
}
} else {
std::fs::create_dir(&screenshot_directory_name).unwrap();
}
// take the page from world
// add network event listener, tracking requests and pairing them with responses
// store them somewhere inside of the world.
let page = world.page.clone();
let mut req_events = page
.event_listener::<EventRequestWillBeSent>()
.await
.unwrap();
let mut resp_events = page
.event_listener::<EventResponseReceived>()
.await
.unwrap();
world.page.enable_log().await.unwrap();
// get log events generated by the browser
let mut log_events = page.event_listener::<EventEntryAdded>().await.unwrap();
// get log events generated by leptos or other console.log() calls..
let mut runtime_events = page
.event_listener::<EventConsoleApiCalled>()
.await
.unwrap();
let console_logs = world.console_logs.clone();
let console_logs_2 = world.console_logs.clone();
tokio::task::spawn(async move {
while let Some(event) = log_events.next().await {
if let Some(EventEntryAdded { entry }) =
Arc::<EventEntryAdded>::into_inner(event) {
console_logs.write().await.push(format!(" {entry:#?} "));
} else {
tracing::error!("tried to into inner but none")
}
}
});
tokio::task::spawn(async move {
while let Some(event) = runtime_events.next().await {
if let Some(event) =Arc::<EventConsoleApiCalled>::into_inner(event) {
console_logs_2
.write()
.await
.push(format!(" CONSOLE_LOG: {:#?}", event.args));
} else {
tracing::error!("tried to into inner but none")
}
}
});
let (tx, mut rx) = futures::channel::mpsc::channel::<Option<CookieEnum>>(1000);
let mut tx_c = tx.clone();
let mut tx_c_2 = tx.clone();
world.cookie_sender = Some(tx);
let req_resp = world.req_resp.clone();
// Ideally you'd send the message for the Page to get the cookies from inside of the event stream loop,
// but for some reason that doesn't always work (but sometimes it does),
// but putting it in it's own thread makes it always work. Not sure why at the moment... ,
// something about async, about senders, about trying to close the browser but keeping senders around.
// we need to close the loop and drop the task to close the browser (I think)...
tokio::task::spawn(async move {
while let Some(some_request_id) = rx.next().await {
if let Some(cookie_enum) = some_request_id {
match cookie_enum {
CookieEnum::BeforeReq(req_id) => {
let cookies = page
.get_cookies()
.await
.unwrap_or_default()
.iter()
.map(|cookie| {
format!("name={}\n value={}", cookie.name, cookie.value)
})
.collect::<Vec<String>>()
.join("\n");
if let Some(thing) = req_resp
.write()
.await
.get_mut(&req_id) {
thing.cookies_before_request = cookies;
}
}
CookieEnum::AfterResp(req_id) => {
let cookies = page
.get_cookies()
.await
.unwrap_or_default()
.iter()
.map(|cookie| {
format!("name={}\n value={}", cookie.name, cookie.value)
})
.collect::<Vec<String>>()
.join("\n");
if let Some(thing) = req_resp
.write()
.await
.get_mut(&req_id) {
thing.cookies_after_response = cookies;
}
}
}
} else {
break;
}
}
});
let req_resp = world.req_resp.clone();
tokio::task::spawn(async move {
while let Some(event) = req_events.next().await {
if let Some(event) = Arc::<EventRequestWillBeSent>::into_inner(event) {
if event.request.url.contains("/pkg/") {
continue;
}
let req_id = event.request_id.inner().clone();
req_resp.write().await.insert(
req_id.clone(),
RequestPair {
req: Some(event.request),
redirect_resp: event.redirect_response,
resp: None,
cookies_before_request: "".to_string(),
cookies_after_response: "".to_string(),
ts: std::time::Instant::now(),
},
);
if let Err(msg) = tx_c.try_send(Some(CookieEnum::BeforeReq(req_id.clone()))) {
tracing::error!(" oopsies on the {msg:#?}");
}
} else {
tracing::error!("into inner err")
}
}
});
let req_resp = world.req_resp.clone();
tokio::task::spawn(async move {
while let Some(event) = resp_events.next().await {
if let Some(event) = Arc::<EventResponseReceived>::into_inner(event){
if event.response.url.contains("/pkg/") {
continue;
}
let req_id = event.request_id.inner().clone();
if let Err(msg) = tx_c_2
.try_send(Some(CookieEnum::AfterResp(req_id.clone()))) {
tracing::error!("err sending {msg:#?}");
}
if let Some(request_pair) = req_resp.write().await.get_mut(&req_id) {
request_pair.resp = Some(event.response);
} else {
req_resp.write().await.insert(
req_id.clone(),
RequestPair {
req: None,
redirect_resp: None,
resp: Some(event.response),
cookies_before_request: "No cookie?".to_string(),
cookies_after_response: "No cookie?".to_string(),
ts: std::time::Instant::now(),
},
);
}
} else {
tracing::error!(" uhh err here")
}
}
});
// We don't need to join on our join handles, they will run detached and clean up whenever.
})
})
.after(|_feature, _rule, scenario, ev, world| {
Box::pin(async move {
let screenshot_directory_name = format!("./screenshots/{}", scenario.name);
let world = world.unwrap();
// screenshot the last step
if let Ok(screenshot) = world
.page
.screenshot(
ScreenshotParams::builder()
.capture_beyond_viewport(true)
.full_page(true)
.build(),
)
.await {
world.screenshots.push(screenshot);
}
if let cucumber::event::ScenarioFinished::StepFailed(_, _, _) = ev {
// close the cookie task.
if world
.cookie_sender
.as_mut()
.unwrap()
.try_send(None).is_err() {
tracing::error!("can't close cookie sender");
}
// print any applicable screenshots (just the last one of the failed step if there was none taken during the scenario)
for (i, screenshot) in world.screenshots.iter().enumerate() {
// i.e ./screenshots/login/1.png
_ =std::fs::write(
screenshot_directory_name.clone()
+ "/"
+ i.to_string().as_str()
+ ".png",
screenshot,
);
}
// print network
let mut network_output = world
.req_resp
.read()
.await
.values()
.map(|val| val.clone())
.collect::<Vec<RequestPair>>();
network_output.sort_by(|a, b| a.ts.cmp(&b.ts));
let network_output = network_output
.into_iter()
.map(|val| val.to_string())
.collect::<Vec<String>>()
.join("\n");
_ = std::fs::write("./network_output", network_output.as_bytes());
let console_logs = world.console_logs.read().await.join("\n");
_ =std::fs::write("./console_logs", console_logs.as_bytes());
// print html
if let Ok(html) = world.page.content().await {
_ = std::fs::write("./html", html.as_bytes());
}
}
if let Err(err) = world.browser.close().await {
tracing::error!("{err:#?}");
}
if let Err(err) = world.browser.wait().await {
tracing::error!("{err:#?}");
}
})
})
.run_and_exit("./features")
.await;
Ok(())
}
#[tracing::instrument]
async fn build_browser() -> Result<Browser, Box<dyn std::error::Error>> {
let (browser, mut handler) = Browser::launch(
BrowserConfig::builder()
//.enable_request_intercept()
.disable_cache()
.request_timeout(Duration::from_secs(1))
//.with_head()
//.arg("--remote-debugging-port=9222")
.build()?,
)
.await?;
tokio::task::spawn(async move {
while let Some(h) = handler.next().await {
if h.is_err() {
tracing::info!("{h:?}");
break;
}
}
});
Ok(browser)
}
pub const HOST: &str = "https://127.0.0.1:3000";
#[derive(World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub browser: Browser,
pub page: Page,
pub req_resp: Arc<RwLock<HashMap<String, RequestPair>>>,
pub clipboard: HashMap<&'static str, String>,
pub cookie_sender: Option<Sender<Option<CookieEnum>>>,
pub screenshots: Vec<Vec<u8>>,
pub console_logs: Arc<RwLock<Vec<String>>>,
}
impl std::fmt::Debug for AppWorld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppWorld").finish()
}
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let browser = build_browser().await.unwrap();
let page = browser.new_page("about:blank").await?;
Ok(Self {
browser,
page,
req_resp: Arc::new(RwLock::new(HashMap::new())),
clipboard: HashMap::new(),
cookie_sender: None,
screenshots: Vec::new(),
console_logs: Arc::new(RwLock::new(Vec::new())),
})
}
pub async fn errors(&mut self) -> Result<()> {
if let Ok(error) = self.find(ids::ERROR_ERROR_ID).await {
Err(anyhow!("{}", error.inner_text().await?.unwrap_or(String::from("no error in inner template?"))))
} else {
Ok(())
}
}
pub async fn find(&self, id: &'static str) -> Result<Element> {
for _ in 0..4 {
if let Ok(el) = self.page.find_element(format!("#{id}")).await {
return Ok(el);
}
crate::fixtures::wait().await;
}
Err(anyhow!("Can't find {id}"))
}
pub async fn find_submit(&mut self) -> Result<Element> {
for _ in 0..4 {
if let Ok(el) = self.page.find_element(format!("input[type=submit]")).await {
return Ok(el);
}
crate::fixtures::wait().await;
}
Err(anyhow!("Can't find input type=submit"))
}
/*pub async fn find_all(&mut self, id: &'static str) -> Result<ElementList> {
Ok(ElementList(
self.page.find_elements(format!("#{id}")).await?,
))
}*/
pub async fn goto_url(&mut self, url: &str) -> Result<()> {
self.page
.goto(
NavigateParams::builder()
.url(url)
.build()
.map_err(|err| anyhow!(err))?,
)
.await?
.wait_for_navigation()
.await?;
self.screenshot().await?;
Ok(())
}
pub async fn goto_path(&mut self, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
self.page
.goto(
NavigateParams::builder()
.url(url)
.build()
.map_err(|err| anyhow!(err))?,
)
.await?;
self.screenshot().await?;
Ok(())
}
pub async fn screenshot(&mut self) -> Result<()> {
let sc = self.page.screenshot(ScreenshotParams::default()).await?;
self.screenshots.push(sc);
Ok(())
}
pub async fn set_field<S: AsRef<str> + std::fmt::Display>(
&mut self,
id: &'static str,
value: S,
) -> Result<()> {
let element = self.find(id).await?;
element.focus().await?.type_str(value).await?;
self.screenshot().await?;
Ok(())
}
pub async fn click(&mut self, id: &'static str) -> Result<()> {
self.find(id).await?.click().await?;
Ok(())
}
#[tracing::instrument(err)]
pub async fn submit(&mut self) -> Result<()> {
self.screenshot().await?;
self.find_submit().await?.click().await?;
Ok(())
}
pub async fn find_text(&self, text: String) -> Result<Element> {
let selector: String = format!("//*[contains(text(), '{text}') or @*='{text}']");
let mut count = 0;
loop {
let result = self.page.find_xpath(&selector).await;
if result.is_err() && count < 4 {
count += 1;
crate::fixtures::wait().await;
} else {
let result = result?;
return Ok(result);
}
}
}
pub async fn url_contains(&self, s: &'static str) -> Result<()> {
if let Some(current) = self.page.url().await? {
if !current.contains(s) {
return Err(anyhow!("{current} does not contains {s}"));
}
} else {
return Err(anyhow!("NO CURRENT URL FOUND"));
}
Ok(())
}
pub async fn verify_route(&self, path: &'static str) -> Result<()> {
let url = format!("{}{}", HOST, path);
if let Some(current) = self.page.url().await? {
if current != url {
return Err(anyhow!(
"EXPECTING ROUTE: {path}\n but FOUND:\n {current:#?}"
));
}
} else {
return Err(anyhow!(
"EXPECTING ROUTE: {path}\n but NO CURRENT URL FOUND"
));
}
Ok(())
}
}
/*
#[derive(Debug)]
pub struct ElementList(Vec<Element>);
impl ElementList {
/// iterates over elements, finds first element whose text (as rendered) contains text given as function's argument.
pub async fn find_by_text(&self,text:&'static str) -> Result<Element> {
for element in self.0.iter() {
if let Ok(Some(inner_text)) = element.inner_text().await {
if inner_text.contains(text) {
return Ok(element);
}
}
}
Err(anyhow!(format!("given text {} no element found",text)))
}
}*/
#[derive(Serialize, Deserialize, Debug)]
struct Email {
id: Uuid,
to: Vec<Recipient>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Recipient {
name: Option<String>,
email: String,
}

View File

@ -0,0 +1,42 @@
pub mod steps;
use anyhow::{anyhow, Result};
pub async fn wait() {
tokio::time::sleep(tokio::time::Duration::from_millis(75)).await;
}
use regex::Regex;
fn extract_code_and_link(text: &str) -> Result<(String, String)> {
// Regex pattern for a six-digit number
let number_regex = Regex::new(r"\b\d{6}\b").unwrap();
// Regex pattern for a URL
let url_regex = Regex::new(r">(https?://[^<]+)<").unwrap(); // Simplified URL pattern
// Search for a six-digit number
let number = number_regex
.find(text)
.map(|match_| match_.as_str().to_string())
.ok_or(anyhow!("Can't find number match"))?;
// Search for a URL
let url = url_regex
.find(text)
.map(|match_| match_.as_str().to_string())
.ok_or(anyhow!("Can't find url match in \n {text}"))?;
let url = url.trim_matches(|c| c == '>' || c == '<').to_string();
let url = url.replace("amp;", "");
Ok((number, url))
}
fn extract_code(text: &str) -> Result<String> {
// Regex pattern for a six-digit number
let number_regex = Regex::new(r"\b\d{6}\b").unwrap();
// Search for a six-digit number
let number = number_regex
.find(text)
.map(|match_| match_.as_str().to_string())
.ok_or(anyhow!("Can't find number match"))?;
Ok(number)
}

View File

@ -0,0 +1,531 @@
use crate::{AppWorld, EMAIL_ID_MAP};
use anyhow::anyhow;
use anyhow::{Ok, Result};
use chromiumoxide::cdp::browser_protocol::input::TimeSinceEpoch;
use chromiumoxide::cdp::browser_protocol::network::{CookieParam, DeleteCookiesParams};
use cucumber::{given, then, when};
use fake::locales::EN;
use fake::{faker::internet::raw::FreeEmail, Fake};
use super::wait;
#[given("I pass")]
pub async fn i_pass(_world: &mut AppWorld) -> Result<()> {
tracing::info!("I pass and I trace.");
Ok(())
}
#[given("I am on the homepage")]
pub async fn navigate_to_homepage(world: &mut AppWorld) -> Result<()> {
world.goto_path("/").await?;
Ok(())
}
#[then("I am on the homepage")]
pub async fn check_url_for_homepage(world: &mut AppWorld) -> Result<()> {
world.verify_route("/").await?;
Ok(())
}
#[given("I click register")]
#[when("I click register")]
pub async fn click_register(world: &mut AppWorld) -> Result<()> {
world.click(ids::REGISTER_BUTTON_ID).await?;
Ok(())
}
#[given("I see the registration form")]
#[when("I see the registration form")]
#[then("I see the registration form")]
pub async fn find_registration_form(world: &mut AppWorld) -> Result<()> {
world.find(ids::REGISTRATION_FORM_ID).await?;
Ok(())
}
#[given("I see the login form")]
#[when("I see the login form")]
#[then("I see the login form")]
pub async fn find_login_form(world: &mut AppWorld) -> Result<()> {
world.find(ids::LOGIN_FORM_ID).await?;
Ok(())
}
#[given("I am on the registration page")]
pub async fn navigate_to_register(world: &mut AppWorld) -> Result<()> {
world.goto_path("/register").await?;
Ok(())
}
#[given("I enter valid credentials")]
pub async fn fill_form_fields_with_credentials(world: &mut AppWorld) -> Result<()> {
let email = FreeEmail(EN).fake::<String>();
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect(&format!(
"To find element with id {} BUT ERROR : ",
ids::EMAIL_INPUT_ID
));
world.clipboard.insert("email", email);
world
.set_field(ids::PASSWORD_INPUT_ID, ids::PASSWORD)
.await
.expect(&format!(
"To find element with id {} BUT ERROR : ",
ids::PASSWORD_INPUT_ID
));
world.submit().await?;
world.errors().await?;
wait().await;
Ok(())
}
#[given("I enter valid other credentials")]
pub async fn fill_form_fields_with_other_credentials(world: &mut AppWorld) -> Result<()> {
let email = FreeEmail(EN).fake::<String>();
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect(&format!(
"To find element with id {} BUT ERROR : ",
ids::EMAIL_INPUT_ID
));
world.clipboard.insert("other_email", email);
world
.set_field(ids::PASSWORD_INPUT_ID, ids::PASSWORD)
.await
.expect(&format!(
"To find element with id {} BUT ERROR : ",
ids::PASSWORD_INPUT_ID
));
world.submit().await?;
world.errors().await?;
wait().await;
Ok(())
}
#[given("I re-enter other valid credentials")]
#[when("I re-enter other valid credentials")]
pub async fn fill_form_fields_with_previous_other_credentials(world: &mut AppWorld) -> Result<()> {
let email = world
.clipboard
.get("other_email")
.cloned()
.ok_or(anyhow!("Can't find other credentials in clipboard"))?;
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect("set email field");
world
.set_field(ids::PASSWORD_INPUT_ID, ids::PASSWORD)
.await
.expect("set password field");
world.submit().await?;
world.errors().await?;
Ok(())
}
#[when("I enter valid credentials")]
#[when("I re-enter valid credentials")]
#[given("I re-enter valid credentials")]
pub async fn fill_form_fields_with_previous_credentials(world: &mut AppWorld) -> Result<()> {
let email = world.clipboard.get("email").cloned();
let email = if let Some(email) = email {
email
} else {
let email = FreeEmail(EN).fake::<String>();
world.clipboard.insert("email", email.clone());
email
};
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect("set email field");
world
.set_field(ids::PASSWORD_INPUT_ID, ids::PASSWORD)
.await
.expect("set password field");
world.submit().await?;
world.errors().await?;
Ok(())
}
#[then("I am on the verify email page")]
pub async fn check_url_to_be_verify_page(world: &mut AppWorld) -> Result<()> {
world.find(ids::VERIFY_EMAIL_DIV_ID).await?;
Ok(())
}
#[given("I check my other email for the verification link and code")]
#[when("I check my other email for the verification link and code")]
pub async fn check_email_other_for_verification_link_and_code(world: &mut AppWorld) -> Result<()> {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// we've stored the email with the id
// so we get the id with our email from our clipboard
let email = world
.clipboard
.get("other_email")
.ok_or(anyhow!("email not found in clipboard"))?;
let id = EMAIL_ID_MAP
.read()
.await
.get(email)
.ok_or(anyhow!("{email} not found in EMAIL_ID_MAP"))?
.clone();
// then we use the id to get the message from mailcrab
let body = reqwest::get(format!("http://127.0.0.1:1080/api/message/{}/body", id))
.await
.unwrap()
.text()
.await
.unwrap();
let (code, link) = super::extract_code_and_link(&body)?;
world.clipboard.insert("code", code);
world.clipboard.insert("link", link);
Ok(())
}
#[given("I check my email for the verification link and code")]
#[when("I check my email for the verification link and code")]
pub async fn check_email_for_verification_link_and_code(world: &mut AppWorld) -> Result<()> {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// we've stored the email with the id
// so we get the id with our email from our clipboard
let email = world
.clipboard
.get("email")
.ok_or(anyhow!("email not found in clipboard"))?;
let id = EMAIL_ID_MAP
.read()
.await
.get(email)
.ok_or(anyhow!("{email} not found in EMAIL_ID_MAP"))?
.clone();
// then we use the id to get the message from mailcrab
let body = reqwest::get(format!("http://127.0.0.1:1080/api/message/{}/body", id))
.await
.unwrap()
.text()
.await
.unwrap();
let (code, link) = super::extract_code_and_link(&body)?;
world.clipboard.insert("code", code);
world.clipboard.insert("link", link);
Ok(())
}
#[given("I copy the code onto the verification link page")]
#[when("I copy the code onto the verification link page")]
pub async fn copy_code_onto_verification_page(world: &mut AppWorld) -> Result<()> {
let link = world
.clipboard
.get("link")
.ok_or(anyhow!("link not found in clipboard"))?
.clone();
world.goto_url(&link).await?;
let code = world
.clipboard
.get("code")
.ok_or(anyhow!("link not found in clipboard"))?
.clone();
world
.set_field(ids::VERFICATION_CODE_ID, code)
.await
.expect(&format!("Can't find {}", ids::VERFICATION_CODE_ID));
world.submit().await?;
world.click("continue").await?;
wait().await;
Ok(())
}
#[when("I click login")]
#[given("I click login")]
pub async fn click_login(world: &mut AppWorld) -> Result<()> {
world.click(ids::LOGIN_BUTTON_ID).await?;
wait().await;
Ok(())
}
#[given("I click logout")]
#[when("I click logout")]
pub async fn click_logout(world: &mut AppWorld) -> Result<()> {
world.click(ids::LOGOUT_BUTTON_ID).await?;
wait().await;
world.errors().await?;
Ok(())
}
#[tracing::instrument]
#[given("I am logged out")]
#[then("I am logged out")]
pub async fn check_ory_kratos_cookie_doesnt_exist(world: &mut AppWorld) -> Result<()> {
let cookies = world.page.get_cookies().await?;
if !cookies
.iter()
.filter(|c| c.name.contains("ory_kratos_session"))
.collect::<Vec<_>>()
.is_empty()
{
tracing::error!("{cookies:#?}");
Err(anyhow!("Ory kratos cookie exists."))
} else {
Ok(())
}
}
#[then("I am logged in")]
#[given("I am logged in")]
pub async fn check_ory_kratos_cookie_exists(world: &mut AppWorld) -> Result<()> {
if world
.page
.get_cookies()
.await?
.iter()
.filter(|c| c.name.contains("ory_kratos_session"))
.collect::<Vec<_>>()
.is_empty()
{
Err(anyhow!("Ory kratos cookie doesn't exists."))
} else {
Ok(())
}
}
#[given("I add example post")]
#[when("I add example post")]
pub async fn add_content_to_box(world: &mut AppWorld) -> Result<()> {
let content: Vec<String> = fake::faker::lorem::en::Words(0..10).fake();
let content = content.join(" ");
world.clipboard.insert("content", content.clone());
world
.set_field(ids::POST_POST_TEXT_AREA_ID, content)
.await?;
world.click(ids::POST_POST_SUBMIT_ID).await?;
Ok(())
}
#[given("I see example content posted")]
#[then("I see example content posted")]
#[when("I see example content posted")]
pub async fn see_my_content_posted(world: &mut AppWorld) -> Result<()> {
world.click(ids::POST_SHOW_LIST_BUTTON_ID).await?;
let content = world
.clipboard
.get("content")
.cloned()
.ok_or(anyhow!("Can't find content in clipboard"))?;
world.errors().await?;
let _ = world.find_text(content).await?;
Ok(())
}
#[when("I see error")]
#[then("I see error")]
pub async fn see_err(world: &mut AppWorld) -> Result<()> {
wait().await;
if world.errors().await.is_ok() {
return Err(anyhow!("Expecting an error."));
}
Ok(())
}
#[when("I don't see error")]
#[then("I don't see error")]
pub async fn dont_see_err(world: &mut AppWorld) -> Result<()> {
world.errors().await?;
Ok(())
}
#[given("I add other email as editor")]
#[when("I add other email as editor")]
pub async fn add_other_email_as_editor(world: &mut AppWorld) -> Result<()> {
let other_email = world
.clipboard
.get("other_email")
.cloned()
.ok_or(anyhow!("Can't find other email."))?;
world
.set_field(ids::POST_ADD_EDITOR_INPUT_ID, other_email)
.await?;
world.click(ids::POST_ADD_EDITOR_SUBMIT_ID).await?;
Ok(())
}
#[when("I logout")]
pub async fn i_logout(world: &mut AppWorld) -> Result<()> {
world.click(ids::LOGOUT_BUTTON_ID).await?;
world.errors().await?;
Ok(())
}
#[when("I edit example post")]
pub async fn add_new_edit_content_to_previous(world: &mut AppWorld) -> Result<()> {
let edit_content: Vec<String> = fake::faker::lorem::en::Words(0..10).fake();
let edit_content = edit_content.join(" ");
world.clipboard.insert("edit_content", edit_content.clone());
world
.set_field(ids::POST_EDIT_TEXT_AREA_ID, edit_content)
.await?;
world.click(ids::POST_EDIT_SUBMIT_ID).await?;
Ok(())
}
#[then("I see my new content posted")]
pub async fn new_content_boom_ba_da_boom(world: &mut AppWorld) -> Result<()> {
let content = world
.clipboard
.get("edit_content")
.cloned()
.ok_or(anyhow!("Can't find content in clipboard"))?;
world.find_text(content).await?;
Ok(())
}
#[then("I don't see old content")]
pub async fn dont_see_old_content_posted(world: &mut AppWorld) -> Result<()> {
let content = world
.clipboard
.get("content")
.cloned()
.ok_or(anyhow!("Can't find content in clipboard"))?;
if world.find_text(content).await.is_ok() {
return Err(anyhow!("But I do see old content..."));
}
Ok(())
}
#[given("I click show post list")]
#[when("I click show post list")]
pub async fn i_click_show_post_list(world: &mut AppWorld) -> Result<()> {
world.click(ids::POST_SHOW_LIST_BUTTON_ID).await?;
Ok(())
}
#[given("I clear cookies")]
pub async fn i_clear_cookies(world: &mut AppWorld) -> Result<()> {
let cookies = world
.page
.get_cookies()
.await?
.into_iter()
.map(|cookie| {
DeleteCookiesParams::from_cookie(&CookieParam {
name: cookie.name,
value: cookie.value,
url: None, // Since there's no direct field for URL, it's set as None
domain: Some(cookie.domain),
path: Some(cookie.path),
secure: Some(cookie.secure),
http_only: Some(cookie.http_only),
same_site: cookie.same_site,
// Assuming you have a way to convert f64 expires to TimeSinceEpoch
expires: None,
priority: Some(cookie.priority),
same_party: Some(cookie.same_party),
source_scheme: Some(cookie.source_scheme),
source_port: Some(cookie.source_port),
partition_key: cookie.partition_key,
// Note: `partition_key_opaque` is omitted since it doesn't have a direct mapping
})
})
.collect();
world.page.delete_cookies(cookies).await?;
Ok(())
}
#[given("I click recover email")]
pub async fn click_recover_email(world: &mut AppWorld) -> Result<()> {
world.click(ids::RECOVER_EMAIL_BUTTON_ID).await?;
wait().await;
Ok(())
}
#[given("I submit valid recovery email")]
pub async fn submit_valid_recovery_email(world: &mut AppWorld) -> Result<()> {
let email = world
.clipboard
.get("email")
.cloned()
.ok_or(anyhow!("Expecting email in clipboard if recovering email."))?;
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect("set email field");
world.submit().await?;
world.errors().await?;
Ok(())
}
#[given("I check my email for recovery link and code")]
pub async fn check_email_for_recovery_link_and_code(world: &mut AppWorld) -> Result<()> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// we've stored the email with the id
// so we get the id with our email from our clipboard
let email = world
.clipboard
.get("email")
.ok_or(anyhow!("email not found in clipboard"))?;
let id = EMAIL_ID_MAP
.read()
.await
.get(email)
.ok_or(anyhow!("{email} not found in EMAIL_ID_MAP"))?
.clone();
// then we use the id to get the message from mailcrab
let body = reqwest::get(format!("http://127.0.0.1:1080/api/message/{}/body", id))
.await
.unwrap()
.text()
.await
.unwrap();
let code = super::extract_code(&body)?;
world.clipboard.insert("recovery_code", code);
Ok(())
}
#[when("I copy the code onto the recovery link page")]
pub async fn copy_code_onto_recovery_page(world: &mut AppWorld) -> Result<()> {
// we should figure out how to be on the right page, will this just work?
let code = world
.clipboard
.get("recovery_code")
.ok_or(anyhow!("link not found in clipboard"))?
.clone();
world
.set_field(ids::VERFICATION_CODE_ID, code)
.await
.expect(&format!("Can't find {}", ids::VERFICATION_CODE_ID));
world.submit().await?;
wait().await;
Ok(())
}
#[then("I am on the settings page")]
pub async fn im_on_settings_page(world: &mut AppWorld) -> Result<()> {
wait().await;
world.url_contains("/settings").await?;
Ok(())
}
#[given("I enter recovery credentials")]
#[when("I enter recovery credentials")]
pub async fn i_enter_a_new_recovery_password(world: &mut AppWorld) -> Result<()> {
let email = world
.clipboard
.get("email")
.cloned()
.ok_or(anyhow!("Can't find credentials in clipboard"))?;
world
.set_field(ids::EMAIL_INPUT_ID, &email)
.await
.expect("set email field");
world
.set_field(ids::PASSWORD_INPUT_ID, ids::RECOVERY_PASSWORD)
.await
.expect("set password field");
let code = world
.clipboard
.get("recovery_code")
.ok_or(anyhow!("link not found in clipboard"))?
.clone();
world
.set_field(ids::VERFICATION_CODE_ID, code)
.await
.expect(&format!("Can't find {}", ids::VERFICATION_CODE_ID));
world.submit().await?;
wait().await;
Ok(())
}

View File

@ -0,0 +1,19 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
app = { path = "../app", default-features = false, features = ["hydrate"] }
leptos = { workspace = true, features = [ "hydrate" ] }
console_error_panic_hook.workspace = true
console_log.workspace = true
wasm-bindgen.workspace = true
ids = { path="../ids" }

View File

@ -0,0 +1,12 @@
use app::*;
use leptos::*;
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
// initializes logging using the `log` crate
// _ = console_log::init_with_level(tracing::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@ -0,0 +1,6 @@
[package]
name = "ids"
version = "0.1.0"
edition = "2021"
[dependencies]

View File

@ -0,0 +1,63 @@
pub static REGISTER_BUTTON_ID: &'static str = "register_button_id";
pub static REGISTRATION_FORM_ID: &'static str = "registration_form_id";
pub static EMAIL_INPUT_ID: &'static str = "email_input_id";
pub static PASSWORD_INPUT_ID: &'static str = "password_input_id";
pub static VERIFY_EMAIL_DIV_ID: &'static str = "verify_email_div_id";
pub static VERIFICATION_FORM_ID: &'static str = "verification_form_id";
pub static LOGIN_FORM_ID: &'static str = "login_form_id";
pub static REGISTER_ROUTE: &'static str = "/register";
pub static VERIFICATION_ROUTE: &'static str = "/verification";
pub static LOGIN_ROUTE: &'static str = "/login";
pub static KRATOS_ERROR_ROUTE: &'static str = "/kratos_error";
pub static RECOVERY_ROUTE: &'static str = "/recovery";
pub static SETTINGS_ROUTE: &'static str = "/settings";
pub static ERROR_ERROR_ID: &'static str = "error_template_id";
pub static ERROR_COOKIES_ID: &'static str = "error_cookies_id";
pub static VERFICATION_CODE_ID: &'static str = "verification_code_id";
pub static KRATOS_FORM_SUBMIT_ID: &'static str = "kratos_form_submit_id";
pub static LOGOUT_BUTTON_ID: &'static str = "logout_button_id";
pub static LOGIN_BUTTON_ID: &'static str = "login_button_id";
/// This function is for use in kratos_html, it takes the name of the input node and it
/// matches it according to what we've specified in the kratos schema file. If we change the schema.
/// I.e use a phone instead of an email, the identifer id will change and break tests that expect an email.
/// i.e use oidc instead of password, as auth method... that will break tests too.
/// Which is good.
pub fn match_name_to_id(name: String) -> &'static str {
match name.as_str() {
"traits.email" => EMAIL_INPUT_ID,
"identifier" => EMAIL_INPUT_ID,
"email" => EMAIL_INPUT_ID,
"password" => PASSWORD_INPUT_ID,
"code" => VERFICATION_CODE_ID,
"totp_code" => VERFICATION_CODE_ID,
_ => "",
}
}
pub static POST_POST_TEXT_AREA_ID: &'static str = "post_post_text_area_id";
pub static POST_POST_SUBMIT_ID: &'static str = "post_post_submit_id";
pub static POST_ADD_EDITOR_BUTTON_ID: &'static str = "post_add_editor_button_id";
pub static POST_ADD_EDITOR_INPUT_ID: &'static str = "add_editor_input_id";
pub static POST_ADD_EDITOR_SUBMIT_ID: &'static str = "post_add_editor_submit_id";
pub static POST_DELETE_ID: &'static str = "post_delete_id";
pub static POST_EDIT_TEXT_AREA_ID: &'static str = "post_edit_text_area_id";
pub static POST_EDIT_SUBMIT_ID: &'static str = "post_edit_submit_id";
pub static POST_SHOW_LIST_BUTTON_ID: &'static str = "post_show_list_button_id";
pub static CLEAR_COOKIES_BUTTON_ID: &'static str = "clear_cookies_button_id";
pub static RECOVERY_FORM_ID: &'static str = "recovery_form_id";
pub static RECOVER_EMAIL_BUTTON_ID: &'static str = "recover_email_button_id";
pub static RECOVERY_PASSWORD: &'static str = "RECOVERY_SuPeRsAfEpAsSwOrD1234!";
pub static PASSWORD: &'static str = "SuPeRsAfEpAsSwOrD1234!";
pub static SETTINGS_FORM_ID: &'static str = "settings_form_id";

View File

@ -0,0 +1,42 @@
{
"$id": "email-schema",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailPerson",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"totp": {
"account_name": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
}
},
"required": [
"email"
],
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,113 @@
version: v1.1.0
dsn: memory
serve:
public:
base_url: http://127.0.0.1:4433/
cors:
enabled: false
allowed_headers:
- Cookie
- Content-Type
- x-csrf-token
- accept
exposed_headers:
- Cookie
- Content-Type
- Set-Cookie
- x-csrf-token
- accept
admin:
base_url: http://127.0.0.1:4434/
selfservice:
default_browser_return_url: https://127.0.0.1:3000/
allowed_return_urls:
- https://127.0.0.1:3000
methods:
password:
enabled: true
totp:
config:
issuer: Kratos
enabled: true
code:
enabled: true
oidc:
flows:
error:
ui_url: https://127.0.0.1:3000/kratos_error
settings:
ui_url: https://127.0.0.1:3000/settings
privileged_session_max_age: 15m
required_aal: aal1
recovery:
enabled: true
ui_url: https://127.0.0.1:3000/recovery
use: code
verification:
enabled: true
ui_url: https://127.0.0.1:3000/verification
use: code
after:
default_browser_return_url: https://127.0.0.1:3000/
logout:
after:
default_browser_return_url: https://127.0.0.1:3000/login
login:
ui_url: https://127.0.0.1:3000/login
after:
default_browser_return_url: https://127.0.0.1:3000
lifespan: 10m
registration:
lifespan: 10m
ui_url: https://127.0.0.1:3000/registration
after:
password:
hooks:
- hook: session
- hook: show_verification_ui
log:
level: trace
format: json
leak_sensitive_values: true
secrets:
cookie:
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
cipher:
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 8
identity:
default_schema_id: email_v0
schemas:
- id: email_v0
url: file:///etc/config/kratos/email.schema.json
courier:
smtp:
connection_uri: smtp://user:pass@mailcrab:1025/?disable_starttls=true&skip_ssl_verify=true
feature_flags:
use_continue_with_transitions: true

View File

@ -0,0 +1,6 @@
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
identity_id TEXT NOT NULL,
email TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_identity_id ON users (identity_id);

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS posts (
post_id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS post_permissions (
post_id TEXT NOT NULL,
user_id TEXT NOT NULL,
read BOOL NOT NULL,
write BOOL NOT NULL,
`delete` BOOL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (post_id) REFERENCES posts(post_id),
PRIMARY KEY (post_id, user_id)
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Some files were not shown because too many files have changed in this diff Show More