diff --git a/projects/meilisearch-searchbar/.gitignore b/projects/meilisearch-searchbar/.gitignore new file mode 100644 index 000000000..f534b27d3 --- /dev/null +++ b/projects/meilisearch-searchbar/.gitignore @@ -0,0 +1,5 @@ +/target +meilisearch +data.ms +dumps +Cargo.lock \ No newline at end of file diff --git a/projects/meilisearch-searchbar/Cargo.toml b/projects/meilisearch-searchbar/Cargo.toml new file mode 100644 index 000000000..6cce5ff22 --- /dev/null +++ b/projects/meilisearch-searchbar/Cargo.toml @@ -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 diff --git a/projects/meilisearch-searchbar/Makefile.toml b/projects/meilisearch-searchbar/Makefile.toml new file mode 100644 index 000000000..fc4f6a85a --- /dev/null +++ b/projects/meilisearch-searchbar/Makefile.toml @@ -0,0 +1,4 @@ +[tasks.ci] +description = "Continuous Integration task" +command = "cargo" +args = ["test"] diff --git a/projects/meilisearch-searchbar/README.md b/projects/meilisearch-searchbar/README.md new file mode 100644 index 000000000..8f2b72a91 --- /dev/null +++ b/projects/meilisearch-searchbar/README.md @@ -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. +

+We'll run meilisearch locally, as opposed to using their cloud service. +

+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! \ No newline at end of file diff --git a/projects/meilisearch-searchbar/data_set.csv b/projects/meilisearch-searchbar/data_set.csv new file mode 100644 index 000000000..09d2b4f02 --- /dev/null +++ b/projects/meilisearch-searchbar/data_set.csv @@ -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 +"McDonald’s","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 +"Moody’s","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 +"Lowe’s","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 +"O’Reilly 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 +"Domino’s 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 \ No newline at end of file diff --git a/projects/meilisearch-searchbar/src/fallback.rs b/projects/meilisearch-searchbar/src/fallback.rs new file mode 100644 index 000000000..ee9851fdd --- /dev/null +++ b/projects/meilisearch-searchbar/src/fallback.rs @@ -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, + req: Request, +) -> 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, (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), + )), + } +} diff --git a/projects/meilisearch-searchbar/src/lib.rs b/projects/meilisearch-searchbar/src/lib.rs new file mode 100644 index 000000000..730253c2c --- /dev/null +++ b/projects/meilisearch-searchbar/src/lib.rs @@ -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. + let search_results = create_rw_signal(Vec::::new()); + provide_context(search_results); + view! { + + + +
+ + + + }/> + +
+
+ } +} + +#[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, ServerFnError> { + use leptos_axum::extract; + // Wow, so ergonomic! + let axum::Extension::(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::() + .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::>>().write_only(); + let search_query = create_server_action::(); + 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! { +
+ + +
+ } +} + +#[component] +pub fn SearchResults() -> impl IntoView { + let read_search_results = expect_context::>>().read_only(); + view! { +
    + + {format!("{name}; last: {last}; high: {high}; low: {low}; chg.: {absolute_change}; chg...:{percentage_change}; volume:{volume}")} + + } + } + /> +
+ } +} diff --git a/projects/meilisearch-searchbar/src/main.rs b/projects/meilisearch-searchbar/src/main.rs new file mode 100644 index 000000000..dfe164f95 --- /dev/null +++ b/projects/meilisearch-searchbar/src/main.rs @@ -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 = 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::(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(); +} diff --git a/projects/nginx-mpmc/.gitignore b/projects/nginx-mpmc/.gitignore new file mode 100644 index 000000000..7ededfcf7 --- /dev/null +++ b/projects/nginx-mpmc/.gitignore @@ -0,0 +1,3 @@ +/target +*/target +.vscode diff --git a/projects/nginx-mpmc/README.md b/projects/nginx-mpmc/README.md new file mode 100644 index 000000000..6cb3a26fa --- /dev/null +++ b/projects/nginx-mpmc/README.md @@ -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 +``` + +
+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. +
+App-1, And App-2 are SSR rendering leptos servers. +
+If you go to localhost (you'll get App-1), and localhost/app2 (you'll get app2). +
+The two shared servers can be communicated with via actions and local resources, or resources (if using CSR). +
+`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. +
+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! \ No newline at end of file diff --git a/projects/nginx-mpmc/app-1/.gitignore b/projects/nginx-mpmc/app-1/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/projects/nginx-mpmc/app-1/.gitignore @@ -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/ diff --git a/projects/nginx-mpmc/app-1/Cargo.toml b/projects/nginx-mpmc/app-1/Cargo.toml new file mode 100644 index 000000000..17937d138 --- /dev/null +++ b/projects/nginx-mpmc/app-1/Cargo.toml @@ -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 //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" diff --git a/projects/nginx-mpmc/app-1/LICENSE b/projects/nginx-mpmc/app-1/LICENSE new file mode 100644 index 000000000..4d209962a --- /dev/null +++ b/projects/nginx-mpmc/app-1/LICENSE @@ -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. diff --git a/projects/nginx-mpmc/app-1/README.md b/projects/nginx-mpmc/app-1/README.md new file mode 100644 index 000000000..ac4da7932 --- /dev/null +++ b/projects/nginx-mpmc/app-1/README.md @@ -0,0 +1,86 @@ + + + Leptos Logo + + +# 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. diff --git a/projects/nginx-mpmc/app-1/public/favicon.ico b/projects/nginx-mpmc/app-1/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/nginx-mpmc/app-1/public/favicon.ico differ diff --git a/projects/nginx-mpmc/app-1/src/app.rs b/projects/nginx-mpmc/app-1/src/app.rs new file mode 100644 index 000000000..70899e115 --- /dev/null +++ b/projects/nginx-mpmc/app-1/src/app.rs @@ -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 + // id=leptos means cargo-leptos will hot-reload this stylesheet + + + // sets the document title + + + // 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()} + } +} diff --git a/projects/nginx-mpmc/app-1/src/error_template.rs b/projects/nginx-mpmc/app-1/src/error_template.rs new file mode 100644 index 000000000..1e0508da5 --- /dev/null +++ b/projects/nginx-mpmc/app-1/src/error_template.rs @@ -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> + } + } + /> + } +} diff --git a/projects/nginx-mpmc/app-1/src/fileserv.rs b/projects/nginx-mpmc/app-1/src/fileserv.rs new file mode 100644 index 000000000..e7692f648 --- /dev/null +++ b/projects/nginx-mpmc/app-1/src/fileserv.rs @@ -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}"), + )), + } +} diff --git a/projects/nginx-mpmc/app-1/src/lib.rs b/projects/nginx-mpmc/app-1/src/lib.rs new file mode 100644 index 000000000..ac3668783 --- /dev/null +++ b/projects/nginx-mpmc/app-1/src/lib.rs @@ -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); +} diff --git a/projects/nginx-mpmc/app-1/src/main.rs b/projects/nginx-mpmc/app-1/src/main.rs new file mode 100644 index 000000000..a09ae6ce5 --- /dev/null +++ b/projects/nginx-mpmc/app-1/src/main.rs @@ -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 +} diff --git a/projects/nginx-mpmc/app-1/style/main.scss b/projects/nginx-mpmc/app-1/style/main.scss new file mode 100644 index 000000000..e4538e156 --- /dev/null +++ b/projects/nginx-mpmc/app-1/style/main.scss @@ -0,0 +1,4 @@ +body { + font-family: sans-serif; + text-align: center; +} \ No newline at end of file diff --git a/projects/nginx-mpmc/app-2/.gitignore b/projects/nginx-mpmc/app-2/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/projects/nginx-mpmc/app-2/.gitignore @@ -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/ diff --git a/projects/nginx-mpmc/app-2/Cargo.toml b/projects/nginx-mpmc/app-2/Cargo.toml new file mode 100644 index 000000000..3aba483da --- /dev/null +++ b/projects/nginx-mpmc/app-2/Cargo.toml @@ -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" diff --git a/projects/nginx-mpmc/app-2/LICENSE b/projects/nginx-mpmc/app-2/LICENSE new file mode 100644 index 000000000..4d209962a --- /dev/null +++ b/projects/nginx-mpmc/app-2/LICENSE @@ -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. diff --git a/projects/nginx-mpmc/app-2/README.md b/projects/nginx-mpmc/app-2/README.md new file mode 100644 index 000000000..1f7b48971 --- /dev/null +++ b/projects/nginx-mpmc/app-2/README.md @@ -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. diff --git a/projects/nginx-mpmc/app-2/public/favicon.ico b/projects/nginx-mpmc/app-2/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/nginx-mpmc/app-2/public/favicon.ico differ diff --git a/projects/nginx-mpmc/app-2/src/app.rs b/projects/nginx-mpmc/app-2/src/app.rs new file mode 100644 index 000000000..6374941fc --- /dev/null +++ b/projects/nginx-mpmc/app-2/src/app.rs @@ -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()} + } +} + diff --git a/projects/nginx-mpmc/app-2/src/error_template.rs b/projects/nginx-mpmc/app-2/src/error_template.rs new file mode 100644 index 000000000..1e0508da5 --- /dev/null +++ b/projects/nginx-mpmc/app-2/src/error_template.rs @@ -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> + } + } + /> + } +} diff --git a/projects/nginx-mpmc/app-2/src/fileserv.rs b/projects/nginx-mpmc/app-2/src/fileserv.rs new file mode 100644 index 000000000..f4de6c70b --- /dev/null +++ b/projects/nginx-mpmc/app-2/src/fileserv.rs @@ -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}"), + )), + } +} diff --git a/projects/nginx-mpmc/app-2/src/lib.rs b/projects/nginx-mpmc/app-2/src/lib.rs new file mode 100644 index 000000000..ac3668783 --- /dev/null +++ b/projects/nginx-mpmc/app-2/src/lib.rs @@ -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); +} diff --git a/projects/nginx-mpmc/app-2/src/main.rs b/projects/nginx-mpmc/app-2/src/main.rs new file mode 100644 index 000000000..9578db922 --- /dev/null +++ b/projects/nginx-mpmc/app-2/src/main.rs @@ -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 +} diff --git a/projects/nginx-mpmc/app-2/style/main.scss b/projects/nginx-mpmc/app-2/style/main.scss new file mode 100644 index 000000000..e4538e156 --- /dev/null +++ b/projects/nginx-mpmc/app-2/style/main.scss @@ -0,0 +1,4 @@ +body { + font-family: sans-serif; + text-align: center; +} \ No newline at end of file diff --git a/projects/nginx-mpmc/kill.sh b/projects/nginx-mpmc/kill.sh new file mode 100755 index 000000000..4ee6a9644 --- /dev/null +++ b/projects/nginx-mpmc/kill.sh @@ -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 \ No newline at end of file diff --git a/projects/nginx-mpmc/nginx.conf b/projects/nginx-mpmc/nginx.conf new file mode 100644 index 000000000..7b89110de --- /dev/null +++ b/projects/nginx-mpmc/nginx.conf @@ -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; + } + } +} \ No newline at end of file diff --git a/projects/nginx-mpmc/nginx_linux.conf b/projects/nginx-mpmc/nginx_linux.conf new file mode 100644 index 000000000..7d98b3511 --- /dev/null +++ b/projects/nginx-mpmc/nginx_linux.conf @@ -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; + } + } +} \ No newline at end of file diff --git a/projects/nginx-mpmc/run.sh b/projects/nginx-mpmc/run.sh new file mode 100755 index 000000000..ccf323114 --- /dev/null +++ b/projects/nginx-mpmc/run.sh @@ -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) diff --git a/projects/nginx-mpmc/run_linux.sh b/projects/nginx-mpmc/run_linux.sh new file mode 100755 index 000000000..8fa7074c4 --- /dev/null +++ b/projects/nginx-mpmc/run_linux.sh @@ -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) diff --git a/projects/nginx-mpmc/shared-server-1/.gitignore b/projects/nginx-mpmc/shared-server-1/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/.gitignore @@ -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/ diff --git a/projects/nginx-mpmc/shared-server-1/Cargo.toml b/projects/nginx-mpmc/shared-server-1/Cargo.toml new file mode 100644 index 000000000..c6b16ee69 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/Cargo.toml @@ -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. \ No newline at end of file diff --git a/projects/nginx-mpmc/shared-server-1/LICENSE b/projects/nginx-mpmc/shared-server-1/LICENSE new file mode 100644 index 000000000..4d209962a --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/LICENSE @@ -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. diff --git a/projects/nginx-mpmc/shared-server-1/README.md b/projects/nginx-mpmc/shared-server-1/README.md new file mode 100644 index 000000000..4f29ff381 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/README.md @@ -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. diff --git a/projects/nginx-mpmc/shared-server-1/public/favicon.ico b/projects/nginx-mpmc/shared-server-1/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/nginx-mpmc/shared-server-1/public/favicon.ico differ diff --git a/projects/nginx-mpmc/shared-server-1/src/lib.rs b/projects/nginx-mpmc/shared-server-1/src/lib.rs new file mode 100644 index 000000000..2466496c3 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/src/lib.rs @@ -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. \ No newline at end of file diff --git a/projects/nginx-mpmc/shared-server-1/src/main.rs b/projects/nginx-mpmc/shared-server-1/src/main.rs new file mode 100644 index 000000000..6d6592a99 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/src/main.rs @@ -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 +} diff --git a/projects/nginx-mpmc/shared-server-1/style/main.scss b/projects/nginx-mpmc/shared-server-1/style/main.scss new file mode 100644 index 000000000..e4538e156 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-1/style/main.scss @@ -0,0 +1,4 @@ +body { + font-family: sans-serif; + text-align: center; +} \ No newline at end of file diff --git a/projects/nginx-mpmc/shared-server-2/.gitignore b/projects/nginx-mpmc/shared-server-2/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/.gitignore @@ -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/ diff --git a/projects/nginx-mpmc/shared-server-2/Cargo.toml b/projects/nginx-mpmc/shared-server-2/Cargo.toml new file mode 100644 index 000000000..d37499fbe --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/Cargo.toml @@ -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. \ No newline at end of file diff --git a/projects/nginx-mpmc/shared-server-2/LICENSE b/projects/nginx-mpmc/shared-server-2/LICENSE new file mode 100644 index 000000000..4d209962a --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/LICENSE @@ -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. diff --git a/projects/nginx-mpmc/shared-server-2/README.md b/projects/nginx-mpmc/shared-server-2/README.md new file mode 100644 index 000000000..4f29ff381 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/README.md @@ -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. diff --git a/projects/nginx-mpmc/shared-server-2/public/favicon.ico b/projects/nginx-mpmc/shared-server-2/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/nginx-mpmc/shared-server-2/public/favicon.ico differ diff --git a/projects/nginx-mpmc/shared-server-2/src/lib.rs b/projects/nginx-mpmc/shared-server-2/src/lib.rs new file mode 100644 index 000000000..d06b6d306 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/src/lib.rs @@ -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. \ No newline at end of file diff --git a/projects/nginx-mpmc/shared-server-2/src/main.rs b/projects/nginx-mpmc/shared-server-2/src/main.rs new file mode 100644 index 000000000..244a583f3 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/src/main.rs @@ -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 +} diff --git a/projects/nginx-mpmc/shared-server-2/style/main.scss b/projects/nginx-mpmc/shared-server-2/style/main.scss new file mode 100644 index 000000000..e4538e156 --- /dev/null +++ b/projects/nginx-mpmc/shared-server-2/style/main.scss @@ -0,0 +1,4 @@ +body { + font-family: sans-serif; + text-align: center; +} \ No newline at end of file diff --git a/projects/ory-kratos/.env b/projects/ory-kratos/.env new file mode 100644 index 000000000..f136949d2 --- /dev/null +++ b/projects/ory-kratos/.env @@ -0,0 +1 @@ +DATABASE_URL="sqlite:app.db" \ No newline at end of file diff --git a/projects/ory-kratos/.gitignore b/projects/ory-kratos/.gitignore new file mode 100644 index 000000000..ea38542dd --- /dev/null +++ b/projects/ory-kratos/.gitignore @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/Cargo.toml b/projects/ory-kratos/Cargo.toml new file mode 100644 index 000000000..1d79c3203 --- /dev/null +++ b/projects/ory-kratos/Cargo.toml @@ -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 diff --git a/projects/ory-kratos/LICENSE b/projects/ory-kratos/LICENSE new file mode 100644 index 000000000..fdddb29aa --- /dev/null +++ b/projects/ory-kratos/LICENSE @@ -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> diff --git a/projects/ory-kratos/README.md b/projects/ory-kratos/README.md new file mode 100644 index 000000000..f34f5729a --- /dev/null +++ b/projects/ory-kratos/README.md @@ -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! diff --git a/projects/ory-kratos/app/Cargo.toml b/projects/ory-kratos/app/Cargo.toml new file mode 100644 index 000000000..833476607 --- /dev/null +++ b/projects/ory-kratos/app/Cargo.toml @@ -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"] + diff --git a/projects/ory-kratos/app/src/auth/extractors.rs b/projects/ory-kratos/app/src/auth/extractors.rs new file mode 100644 index 000000000..c134aff66 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/extractors.rs @@ -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)) + } +} diff --git a/projects/ory-kratos/app/src/auth/kratos_error.rs b/projects/ory-kratos/app/src/auth/kratos_error.rs new file mode 100644 index 000000000..e04866933 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/kratos_error.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/kratos_html.rs b/projects/ory-kratos/app/src/auth/kratos_html.rs new file mode 100644 index 000000000..927786214 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/kratos_html.rs @@ -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} + } +} diff --git a/projects/ory-kratos/app/src/auth/login.rs b/projects/ory-kratos/app/src/auth/login.rs new file mode 100644 index 000000000..1d0b760fb --- /dev/null +++ b/projects/ory-kratos/app/src/auth/login.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/logout.rs b/projects/ory-kratos/app/src/auth/logout.rs new file mode 100644 index 000000000..4d9b5fe69 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/logout.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/mod.rs b/projects/ory-kratos/app/src/auth/mod.rs new file mode 100644 index 000000000..d9b3a79e4 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/mod.rs @@ -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; diff --git a/projects/ory-kratos/app/src/auth/recovery.rs b/projects/ory-kratos/app/src/auth/recovery.rs new file mode 100644 index 000000000..e615a7bb5 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/recovery.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/registration.rs b/projects/ory-kratos/app/src/auth/registration.rs new file mode 100644 index 000000000..dbcb33a5a --- /dev/null +++ b/projects/ory-kratos/app/src/auth/registration.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/session.rs b/projects/ory-kratos/app/src/auth/session.rs new file mode 100644 index 000000000..483777eb0 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/session.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/settings.rs b/projects/ory-kratos/app/src/auth/settings.rs new file mode 100644 index 000000000..043d3fab5 --- /dev/null +++ b/projects/ory-kratos/app/src/auth/settings.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/auth/verification.rs b/projects/ory-kratos/app/src/auth/verification.rs new file mode 100644 index 000000000..cf44ebc9b --- /dev/null +++ b/projects/ory-kratos/app/src/auth/verification.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/database_calls.rs b/projects/ory-kratos/app/src/database_calls.rs new file mode 100644 index 000000000..12a6c4828 --- /dev/null +++ b/projects/ory-kratos/app/src/database_calls.rs @@ -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, +} diff --git a/projects/ory-kratos/app/src/error_template.rs b/projects/ory-kratos/app/src/error_template.rs new file mode 100644 index 000000000..aa2ed962e --- /dev/null +++ b/projects/ory-kratos/app/src/error_template.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/lib.rs b/projects/ory-kratos/app/src/lib.rs new file mode 100644 index 000000000..6d32b4d8b --- /dev/null +++ b/projects/ory-kratos/app/src/lib.rs @@ -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(()) +} diff --git a/projects/ory-kratos/app/src/posts/create_posts.rs b/projects/ory-kratos/app/src/posts/create_posts.rs new file mode 100644 index 000000000..e15bec885 --- /dev/null +++ b/projects/ory-kratos/app/src/posts/create_posts.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/posts/mod.rs b/projects/ory-kratos/app/src/posts/mod.rs new file mode 100644 index 000000000..109b270de --- /dev/null +++ b/projects/ory-kratos/app/src/posts/mod.rs @@ -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; diff --git a/projects/ory-kratos/app/src/posts/post.rs b/projects/ory-kratos/app/src/posts/post.rs new file mode 100644 index 000000000..6946eb31e --- /dev/null +++ b/projects/ory-kratos/app/src/posts/post.rs @@ -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> + } +} diff --git a/projects/ory-kratos/app/src/posts/posts_page.rs b/projects/ory-kratos/app/src/posts/posts_page.rs new file mode 100644 index 000000000..3f5c9d05e --- /dev/null +++ b/projects/ory-kratos/app/src/posts/posts_page.rs @@ -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> + } +} diff --git a/projects/ory-kratos/docker-compose.yml b/projects/ory-kratos/docker-compose.yml new file mode 100644 index 000000000..a7ddfb324 --- /dev/null +++ b/projects/ory-kratos/docker-compose.yml @@ -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 diff --git a/projects/ory-kratos/e2e/Cargo.toml b/projects/ory-kratos/e2e/Cargo.toml new file mode 100644 index 000000000..3f6367cc9 --- /dev/null +++ b/projects/ory-kratos/e2e/Cargo.toml @@ -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 + + + + diff --git a/projects/ory-kratos/e2e/features/0_test.feature b/projects/ory-kratos/e2e/features/0_test.feature new file mode 100644 index 000000000..ad6bfc24d --- /dev/null +++ b/projects/ory-kratos/e2e/features/0_test.feature @@ -0,0 +1,6 @@ +@test +Feature: Test + + Scenario:pass_test_pass + Given I pass + \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/1_register.feature b/projects/ory-kratos/e2e/features/1_register.feature new file mode 100644 index 000000000..190842555 --- /dev/null +++ b/projects/ory-kratos/e2e/features/1_register.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/2_login.feature b/projects/ory-kratos/e2e/features/2_login.feature new file mode 100644 index 000000000..ddde80c8d --- /dev/null +++ b/projects/ory-kratos/e2e/features/2_login.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/3_logout.feature b/projects/ory-kratos/e2e/features/3_logout.feature new file mode 100644 index 000000000..67afb3692 --- /dev/null +++ b/projects/ory-kratos/e2e/features/3_logout.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/4_recovery.feature b/projects/ory-kratos/e2e/features/4_recovery.feature new file mode 100644 index 000000000..583b05d44 --- /dev/null +++ b/projects/ory-kratos/e2e/features/4_recovery.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/5_settings.feature b/projects/ory-kratos/e2e/features/5_settings.feature new file mode 100644 index 000000000..7b9654d34 --- /dev/null +++ b/projects/ory-kratos/e2e/features/5_settings.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/6_add_post.feature b/projects/ory-kratos/e2e/features/6_add_post.feature new file mode 100644 index 000000000..3420dc137 --- /dev/null +++ b/projects/ory-kratos/e2e/features/6_add_post.feature @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/e2e/features/7_edit_post.feature b/projects/ory-kratos/e2e/features/7_edit_post.feature new file mode 100644 index 000000000..59da5793a --- /dev/null +++ b/projects/ory-kratos/e2e/features/7_edit_post.feature @@ -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 diff --git a/projects/ory-kratos/e2e/tests/app_suite.rs b/projects/ory-kratos/e2e/tests/app_suite.rs new file mode 100644 index 000000000..bfcb6ca62 --- /dev/null +++ b/projects/ory-kratos/e2e/tests/app_suite.rs @@ -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, +} diff --git a/projects/ory-kratos/e2e/tests/fixtures/mod.rs b/projects/ory-kratos/e2e/tests/fixtures/mod.rs new file mode 100644 index 000000000..9983bbbcc --- /dev/null +++ b/projects/ory-kratos/e2e/tests/fixtures/mod.rs @@ -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) +} diff --git a/projects/ory-kratos/e2e/tests/fixtures/steps.rs b/projects/ory-kratos/e2e/tests/fixtures/steps.rs new file mode 100644 index 000000000..fc87b07d2 --- /dev/null +++ b/projects/ory-kratos/e2e/tests/fixtures/steps.rs @@ -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(()) +} diff --git a/projects/ory-kratos/frontend/Cargo.toml b/projects/ory-kratos/frontend/Cargo.toml new file mode 100644 index 000000000..5b2339a12 --- /dev/null +++ b/projects/ory-kratos/frontend/Cargo.toml @@ -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" } \ No newline at end of file diff --git a/projects/ory-kratos/frontend/src/lib.rs b/projects/ory-kratos/frontend/src/lib.rs new file mode 100644 index 000000000..bcfda5a6f --- /dev/null +++ b/projects/ory-kratos/frontend/src/lib.rs @@ -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); +} diff --git a/projects/ory-kratos/ids/Cargo.toml b/projects/ory-kratos/ids/Cargo.toml new file mode 100644 index 000000000..631ee6e76 --- /dev/null +++ b/projects/ory-kratos/ids/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ids" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/projects/ory-kratos/ids/src/lib.rs b/projects/ory-kratos/ids/src/lib.rs new file mode 100644 index 000000000..da3433c15 --- /dev/null +++ b/projects/ory-kratos/ids/src/lib.rs @@ -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"; diff --git a/projects/ory-kratos/kratos/email.schema.json b/projects/ory-kratos/kratos/email.schema.json new file mode 100644 index 000000000..c3b509400 --- /dev/null +++ b/projects/ory-kratos/kratos/email.schema.json @@ -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 + } + } + } \ No newline at end of file diff --git a/projects/ory-kratos/kratos/kratos.yaml b/projects/ory-kratos/kratos/kratos.yaml new file mode 100644 index 000000000..b39aee603 --- /dev/null +++ b/projects/ory-kratos/kratos/kratos.yaml @@ -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 \ No newline at end of file diff --git a/projects/ory-kratos/migrations/01_create_users.sql b/projects/ory-kratos/migrations/01_create_users.sql new file mode 100644 index 000000000..3da2cb70e --- /dev/null +++ b/projects/ory-kratos/migrations/01_create_users.sql @@ -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); \ No newline at end of file diff --git a/projects/ory-kratos/migrations/02_create_posts.sql b/projects/ory-kratos/migrations/02_create_posts.sql new file mode 100644 index 000000000..e0b1c4649 --- /dev/null +++ b/projects/ory-kratos/migrations/02_create_posts.sql @@ -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) + ); \ No newline at end of file diff --git a/projects/ory-kratos/migrations/03_create_post_permissions.sql b/projects/ory-kratos/migrations/03_create_post_permissions.sql new file mode 100644 index 000000000..74e5cbc8f --- /dev/null +++ b/projects/ory-kratos/migrations/03_create_post_permissions.sql @@ -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) + ); \ No newline at end of file diff --git a/projects/ory-kratos/public/apple_sso_btn.png b/projects/ory-kratos/public/apple_sso_btn.png new file mode 100644 index 000000000..acc7f85e9 Binary files /dev/null and b/projects/ory-kratos/public/apple_sso_btn.png differ diff --git a/projects/ory-kratos/public/favicon.ico b/projects/ory-kratos/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/ory-kratos/public/favicon.ico differ diff --git a/projects/ory-kratos/rust-toolchain.toml b/projects/ory-kratos/rust-toolchain.toml new file mode 100644 index 000000000..271800cb2 --- /dev/null +++ b/projects/ory-kratos/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/projects/ory-kratos/server/Cargo.toml b/projects/ory-kratos/server/Cargo.toml new file mode 100644 index 000000000..97ee437fc --- /dev/null +++ b/projects/ory-kratos/server/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +app = { path = "../app", default-features = false, features = ["ssr"] } +leptos = { workspace = true, features = [ "ssr" ]} +leptos_axum.workspace = true + +axum.workspace = true +axum-server.workspace = true +sqlx.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +ory-kratos-client.workspace = true \ No newline at end of file diff --git a/projects/ory-kratos/server/src/extract_session.rs b/projects/ory-kratos/server/src/extract_session.rs new file mode 100644 index 000000000..ac9c2d3c3 --- /dev/null +++ b/projects/ory-kratos/server/src/extract_session.rs @@ -0,0 +1,54 @@ +use axum::{async_trait, extract::FromRequestParts, RequestPartsExt}; +use axum_extra::extract::CookieJar; +use http::request::Parts; +use ory_kratos_client::models::session::Session; +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)) + } +} + diff --git a/projects/ory-kratos/server/src/fileserv.rs b/projects/ory-kratos/server/src/fileserv.rs new file mode 100644 index 000000000..06f0d937a --- /dev/null +++ b/projects/ory-kratos/server/src/fileserv.rs @@ -0,0 +1,44 @@ +use app::App; +use axum::response::Response as AxumResponse; +use axum::{ + body::Body, + extract::State, + http::{Request, Response, StatusCode, Uri}, + response::IntoResponse, +}; +use leptos::*; +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(); + 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(), move || view! { <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.map(Body::new)), + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {err}"), + )), + } +} diff --git a/projects/ory-kratos/server/src/main.rs b/projects/ory-kratos/server/src/main.rs new file mode 100644 index 000000000..7c7b80f26 --- /dev/null +++ b/projects/ory-kratos/server/src/main.rs @@ -0,0 +1,68 @@ +use app::*; +use axum::Router; +use axum_server::tls_rustls::RustlsConfig; +use fileserv::file_and_error_handler; +use leptos::*; +use leptos_axum::{generate_route_list, LeptosRoutes}; +use std::path::PathBuf; +use tracing_subscriber::EnvFilter; +pub mod fileserv; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new("debug,tower_http=trace,rustls=error,cookie_store=error,reqwest=error,sqlx=error,hyper=error,h2=error")) + .pretty() + .init(); + + // we get a new db every restart. + + _ = std::fs::remove_file("./app.db"); + _ = std::fs::remove_file("./app.db-shm"); + _ = std::fs::remove_file("./app.db-wal"); + + std::process::Command::new("sqlx") + .args(["db", "create", "--database-url", "sqlite:app.db"]) + .status() + .expect("sqlx to exist on user machine"); + + std::process::Command::new("sqlx") + .args(["migrate", "run", "--database-url", "sqlite:app.db"]) + .status() + .expect("sqlite3 to exist on user machine"); + + let pool = sqlx::SqlitePool::connect("sqlite:app.db").await.unwrap(); + + let config = + RustlsConfig::from_pem_file(PathBuf::from("./cert.pem"), PathBuf::from("./key.pem")) + .await + .unwrap(); + + // 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(None).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() + .leptos_routes(&leptos_options, routes, App) + .fallback(file_and_error_handler) + .layer(axum::Extension(pool)) + .layer(tower_http::trace::TraceLayer::new_for_http()) + .with_state(leptos_options); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + // axum_server::bind_rustl is a wrapper around that + // in real use case we'd want to also run a server that redirects http requests with https to the https server + println!("listening on https://{}", &addr); + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/projects/ory-kratos/style/main.scss b/projects/ory-kratos/style/main.scss new file mode 100644 index 000000000..03881dda7 --- /dev/null +++ b/projects/ory-kratos/style/main.scss @@ -0,0 +1,47 @@ +body { + font-family: sans-serif; + text-align: center; +} + +.google-sign-in-button { + cursor: pointer; + transition: background-color .3s, box-shadow .3s; + + padding: 12px 16px 12px 42px; + border: none; + border-radius: 3px; + box-shadow: 0 -1px 0 rgba(0, 0, 0, .04), 0 1px 1px rgba(0, 0, 0, .25); + + color: #757575; + font-size: 14px; + font-weight: 500; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",sans-serif; + + background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMTcuNiA5LjJsLS4xLTEuOEg5djMuNGg0LjhDMTMuNiAxMiAxMyAxMyAxMiAxMy42djIuMmgzYTguOCA4LjggMCAwIDAgMi42LTYuNnoiIGZpbGw9IiM0Mjg1RjQiIGZpbGwtcnVsZT0ibm9uemVybyIvPjxwYXRoIGQ9Ik05IDE4YzIuNCAwIDQuNS0uOCA2LTIuMmwtMy0yLjJhNS40IDUuNCAwIDAgMS04LTIuOUgxVjEzYTkgOSAwIDAgMCA4IDV6IiBmaWxsPSIjMzRBODUzIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48cGF0aCBkPSJNNCAxMC43YTUuNCA1LjQgMCAwIDEgMC0zLjRWNUgxYTkgOSAwIDAgMCAwIDhsMy0yLjN6IiBmaWxsPSIjRkJCQzA1IiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48cGF0aCBkPSJNOSAzLjZjMS4zIDAgMi41LjQgMy40IDEuM0wxNSAyLjNBOSA5IDAgMCAwIDEgNWwzIDIuNGE1LjQgNS40IDAgMCAxIDUtMy43eiIgZmlsbD0iI0VBNDMzNSIgZmlsbC1ydWxlPSJub256ZXJvIi8+PHBhdGggZD0iTTAgMGgxOHYxOEgweiIvPjwvZz48L3N2Zz4=); + background-color: white; + background-repeat: no-repeat; + background-position: 12px 11px; +} + +.google-sign-in-button:hover { + box-shadow: 0 -1px 0 rgba(0, 0, 0, .04), 0 2px 4px rgba(0, 0, 0, .25); +} + +.google-sign-in-button:active { + background-color: #eeeeee; +} + +.google-sign-in-button:active { + outline: none; + box-shadow: + 0 -1px 0 rgba(0, 0, 0, .04), + 0 2px 4px rgba(0, 0, 0, .25), + 0 0 0 3px #c8dafc; +} + +.google-sign-in-button:disabled { + filter: grayscale(100%); + background-color: #ebebeb; + box-shadow: 0 -1px 0 rgba(0, 0, 0, .04), 0 1px 1px rgba(0, 0, 0, .25); + cursor: not-allowed; +} diff --git a/projects/tauri-from-scratch/.gitignore b/projects/tauri-from-scratch/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/projects/tauri-from-scratch/.gitignore @@ -0,0 +1 @@ +/target diff --git a/projects/tauri-from-scratch/Cargo.toml b/projects/tauri-from-scratch/Cargo.toml new file mode 100644 index 000000000..85f00e344 --- /dev/null +++ b/projects/tauri-from-scratch/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["src-tauri", "src-orig"] + +[profile.release] +codegen-units = 1 +lto = true + diff --git a/projects/tauri-from-scratch/README.md b/projects/tauri-from-scratch/README.md new file mode 100644 index 000000000..2174789b0 --- /dev/null +++ b/projects/tauri-from-scratch/README.md @@ -0,0 +1,584 @@ +This is a guide on how to build a leptos tauri project from scratch without using a template. +<br><br> +First +```sh +cargo new leptos_tauri_from_scratch +``` + +Then, make our two seperate project folders. We need one for our actual app, 'src-orig' and the other is required when using `cargo tauri` +```sh +mkdir src-orig && mkdir src-tauri +``` + +Delete the original src folder. +```sh +rm -r src +``` +Rewrite the `Cargo.toml` file in our crate root to the following. +```toml +[workspace] +resolver = "2" +members = ["src-tauri", "src-orig"] + +[profile.release] +codegen-units = 1 +lto = true +``` +We'll use resolver two because we're using a modern version of Rust. We'll list our workspace members. `codegen-units = 1` and `lto = true` are good things to have for our eventual release, they make the wasm file smaller. +<br><br> +What we're going to do is use `cargo leptos` for building our SSR server and we'll call trunk from `cargo tauri` for building our CSR client that we bundle into our different apps. +Let's add a `Trunk.toml` file. +```toml +[build] +target = "./src-orig/index.html" + +[watch] +ignore = ["./src-tauri"] +``` + +The target of `index.html` is what trunk uses to build the wasm and js files that we'll need for the bundling process when we call `cargo tauri build`. We'll get the resulting files in a `src-orig/dist` folder. +<br> +Create the `index.html` file + +```sh +touch src-orig/index.html +``` + +Let's fill it with +```html +<!DOCTYPE html> +<html> + <head> + <link data-trunk rel="rust" data-wasm-opt="z" data-bin="leptos_tauri_from_scratch_bin"/> + <link rel="icon" type="image/x-icon" href="favicon.ico"> + </head> + <body></body> +</html> +``` +This line +```html +<link data-trunk rel="rust" data-wasm-opt="z" data-bin="leptos_tauri_from_scratch_bin"/> +``` +Tells trunk we want to compile our wasm to be small with `opt="z"` and that our binary will be named `"leptos_tauri_from_scratch_bin"`. <br> +We need to specify that our binary will be a different name then our project name because we are also going to get a wasm file from our library and if we don't use different names then `cargo tauri` will get confused. <br> +More specifically two wasm artifacts will be generated, one for the lib and the other for the binary and it won't know which to use. +<br><br> +Create a favicon that we referenced. +``` +mkdir public && curl https://raw.githubusercontent.com/leptos-rs/leptos/main/examples/animated_show/public/favicon.ico > public/favicon.ico +``` +<br><br> +Let's create a tauri configuration file. +```sh +touch src-tauri/taur.conf.json +``` +And drop this in there +```json +{ + "build": { + "beforeDevCommand": "", + "beforeBuildCommand": "trunk build --no-default-features -v --features \"csr\"", + "devPath": "http://127.0.0.1:3000", + "distDir": "../src-orig/dist" + }, + "package": { + "productName": "leptos_tauri_from_scratch", + "version": "0.1.0" + }, + "tauri": { + "windows": [ + { + "fullscreen": false, + "height": 800, + "resizable": true, + "title": "LeptosChatApp", + "width": 1200 + } + ], + "bundle": { + "active": true, + "category": "DeveloperTool", + "copyright": "", + "deb": { + "depends": [] + }, + "externalBin": [], + "icon": [], + "identifier": "leptos.chat.app", + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null + }, + "resources": [], + "shortDescription": "", + "targets": "all", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "security": { + "csp": null + } + } +} +``` +You can basically ignore all of this except for +```json + "build": { + "beforeDevCommand": "", + "beforeBuildCommand": "trunk build --no-default-features -v --features \"csr\"", + "devPath": "http://127.0.0.1:3000", + "distDir": "../src-orig/dist" + }, +``` +Let's look at +```json + "beforeBuildCommand": "trunk build --no-default-features -v --features \"csr\"", +``` +When we `cargo tauri build` this will run before hand. Trunk will run it's build process, using the index.html file in the src-orig that we specified in `Trunk.toml` <br> +We'll build a binary using only the CSR feature. This is important. <br> +We are going to build an SSR app, and serve it over the internet but we are also going to build a tauri client for desktop and mobile using CSR.<br> +It's going to make network requests to our server that is servering our app to browsers using SSR.<br> + This is the best of both worlds, we get the SEO of SSR and other advantages while being able to use CSR to build our app for other platforms. +``` + "devPath": "http://127.0.0.1:3000", + "distDir": "../src-orig/dist" +``` +Check https://tauri.app/v1/api/config/#buildconfig for what these do, but our before build command `trunk build` will build into a folder `src-orig/dist` which we reference here. +<br><br> +Let's add a `Cargo.toml`` to both of our packages. +```sh +touch src-tauri/Cargo.toml && touch src-orig/Cargo.toml +``` +Let's change `src-tauri/Cargo.toml` to this, we're using the 2.0.0 alpha version of tauri to be able to build to mobile. +```toml +[package] +name = "src_tauri" +version = "0.0.1" +edition = "2021" + +[lib] +name="app_lib" +path="src/lib.rs" + +[build-dependencies] +tauri-build = { version = "2.0.0-alpha.13", features = [] } + +[dependencies] +log = "0.4.0" +serde = { version = "1.0", features = ["derive"] } +tauri = { version = "2.0.0-alpha.20", features = ["devtools"] } +tauri-plugin-http = "2.0.0-alpha.9" + +[features] +#default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] +``` +To make use of `cargo tauri build` we need `tauri-build` and we also need a `build.rs` +``` +touch src-tauri/build.rs +``` +And let's change that to +``` +fn main() { + tauri_build::build(); +} +``` +In our `src-orig/Cargo.toml` let's add. +``` +[package] +name = "leptos_tauri_from_scratch" +version = "0.1.0" +edition = "2021" + + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name="leptos_tauri_from_scratch_bin" +path="./src/main.rs" + +[dependencies] +axum = {version = "0.7.0", optional=true} +axum-macros = { version= "0.4.1", optional=true} +cfg-if = "1.0.0" +console_error_panic_hook = "0.1.7" +console_log = "1.0.0" +leptos = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +leptos-use = "0.9.0" +leptos_axum = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6", optional = true } +leptos_meta = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +leptos_router = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +log = "0.4.20" +serde = "1.0.195" +serde_json = "1.0.111" +server_fn = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +tokio = { version = "1.35.1", optional=true } +tower = {version = "0.4.10", optional = true} +tower-http = { version = "0.5.1", optional = true, features= ["fs","cors"] } +wasm-bindgen = "0.2.89" + +[features] +csr = [ "leptos/csr","leptos_meta/csr","leptos_router/csr", ] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = [ + "dep:axum", + "dep:axum-macros", + "leptos/ssr", + "leptos-use/ssr", + "dep:leptos_axum", + "leptos_meta/ssr", + "leptos_router/ssr", + "dep:tower-http", + "dep:tower", + "dep:tokio", +] + +[package.metadata.leptos] +bin-exe-name="leptos_tauri_from_scratch_bin" +output-name="leptos_tauri_from_scratch" +assets-dir = "../public" +site-pkg-dir = "pkg" +site-root = "target/site" +site-addr = "0.0.0.0:3000" +reload-port = 3001 +browserquery = "defaults" +watch = false +env = "DEV" +bin-features = ["ssr"] +bin-default-features = false +lib-features = ["hydrate"] +lib-default-features = false +``` +So this looks like a normal SSR leptos, except for our CSR, Hydrate, and SSR versions. +```toml +[features] +csr = [ "leptos/csr","leptos_meta/csr","leptos_router/csr", ] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = [ +``` +also our binary is specified and named +```toml +[[bin]] +name="leptos_tauri_from_scratch_bin" +path="./src/main.rs" +``` +our lib is specified, but unnamed (it will default to the project name in cargo leptos and in cargo tauri). We need the different crate types for `cargo leptos serve` and `cargo tauri build` +```toml +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] +``` +We've added the override to our cargo leptos metadata. +```toml +[package.metadata.leptos] +bin-exe-name="leptos_tauri_from_scratch_bin" +``` +Our tauri app is going to send server function calls to this address, this is aksi where we'll serve our hydratable SSR client from. +``` +site-addr = "0.0.0.0:3000" +``` +Now let's create the `main.rs` that we reference in the `src-orig/Cargo.toml` +``` +mkdir src-orig/src && touch src-orig/src/main.rs +``` +and drop this in there... +```rust +cfg_if::cfg_if! { + if #[cfg(feature="ssr")] { + use tower_http::cors::{CorsLayer}; + use axum::{ + Router, + routing::get, + extract::State, + http::Request, + body::Body, + response::IntoResponse + }; + use leptos::{*,provide_context, LeptosOptions}; + use leptos_axum::LeptosRoutes; + use leptos_tauri_from_scratch::fallback::file_and_error_handler; + + #[derive(Clone,Debug,axum_macros::FromRef)] + pub struct ServerState{ + pub options:LeptosOptions, + pub routes: Vec<leptos_router::RouteListing>, + } + + pub async fn server_fn_handler( + State(state): State<ServerState>, + request: Request<Body>, + ) -> impl IntoResponse { + leptos_axum::handle_server_fns_with_context( + move || { + provide_context(state.clone()); + }, + request, + ) + .await + .into_response() + } + + pub async fn leptos_routes_handler( + State(state): State<ServerState>, + req: Request<Body>, + ) -> axum::response::Response { + let handler = leptos_axum::render_route_with_context( + state.options.clone(), + state.routes.clone(), + move || { + provide_context("..."); + }, + leptos_tauri_from_scratch::App, + ); + handler(req).await.into_response() + } + + #[tokio::main] + async fn main() { + let conf = get_configuration(Some("./src-orig/Cargo.toml")).await.unwrap(); + + let leptos_options = conf.leptos_options; + let addr = leptos_options.site_addr; + let routes = leptos_axum::generate_route_list(leptos_tauri_from_scratch::App); + + let state = ServerState{ + options:leptos_options, + routes:routes.clone(), + }; + + let cors = CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin("tauri://localhost".parse::<axum::http::HeaderValue>().unwrap()) + .allow_headers(vec![axum::http::header::CONTENT_TYPE]); + + + let app = Router::new() + .route("/api/*fn_name",get(server_fn_handler).post(server_fn_handler)) + .layer(cors) + .leptos_routes_with_handler(routes, get(leptos_routes_handler)) + .fallback(file_and_error_handler) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + logging::log!("listening on http://{}", &addr); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + } + } else if #[cfg(feature="csr")]{ + pub fn main() { + server_fn::client::set_server_url("http://127.0.0.1:3000"); + leptos::mount_to_body(leptos_tauri_from_scratch::App); + } + } else { + pub fn main() { + + } + } +} +``` +This is our three pronged binary. +When we run cargo leptos server, we're going to get a server that is what's in our `if #[cfg(feature="ssr")] {` branch. We're going to hydrate, that's final `else` branch that is just empty. That actually gets filled in or something with a call to hydrate. +<br> +And our csr feature +```rust + else if #[cfg(feature="csr")]{ + pub fn main() { + server_fn::client::set_server_url("http://127.0.0.1:3000"); + leptos::mount_to_body(leptos_tauri_from_scratch::App); + } + } +``` +Here we're setting the server functions to use the url base that we access in our browser. I.e local host, on the port we specified in the leptos metadata.<br> +Otherwise our tauri app will try to route server function network requests using it's own idea of what it's url is. Which is `tauri://localhost` on macOS, and something else on windows. +<br> +Since we are going to be getting API requests from different locations beside our server's domain let's set up CORS, if you don't do this your tauri apps won't be able to make server function calls because it will run into CORS erros. +```rust + let cors = CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin("tauri://localhost".parse::<axum::http::HeaderValue>().unwrap()) + .allow_headers(vec![axum::http::header::CONTENT_TYPE]); +``` + +If you are on windows the origin of your app will be different then `tauri://localhost` and you'll need to figure that out, as well as if you deploy it to places that aren't your localhost! +<br> +Everything else is standard leptos, so let's fill in the fallback and the lib really quick. +```sh +touch src-orig/src/lib.rs && touch src-orig/src/fallback.rs +``` + +Let's dump this bog standard leptos code in the `src-orig/src/lib.rs`` +```rust +use leptos::*; + +#[cfg(feature = "ssr")] +pub mod fallback; + +#[server(endpoint = "hello_world")] +pub async fn hello_world_server() -> Result<String, ServerFnError> { + Ok("Hey.".to_string()) +} + +#[component] +pub fn App() -> impl IntoView { + let action = create_server_action::<HelloWorldServer>(); + let vals = create_rw_signal(String::new()); + create_effect(move |_| { + if let Some(resp) = action.value().get() { + match resp { + Ok(val) => vals.set(val), + Err(err) => vals.set(format!("{err:?}")), + } + } + }); + view! {<button + on:click=move |_| { + action.dispatch(HelloWorldServer{}); + } + >"Hello world."</button> + { + move || vals.get() + } + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "hydrate")] { + use wasm_bindgen::prelude::wasm_bindgen; + + #[wasm_bindgen] + pub fn hydrate() { + #[cfg(debug_assertions)] + console_error_panic_hook::set_once(); + leptos::mount_to_body(App); + } + } +} +``` +and add this to `src-org/src/fallback.rs` +```rust +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(); + 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(), move || view! {404}); + 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(); + 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}"), + )), + } +} +``` +Let's fill in our src-tauri/src folder. +``` +mkdir src-tauri/src && touch src-tauri/src/main.rs && touch src-tauri/src/lib.rs +``` +and drop this in `src-tauri/src/main.rs` This is standard tauri boilerplate. +```rust +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} +``` +and in `src-tauri/src/lib.rs` +```rust +use tauri::Manager; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .setup(|app| { + { + let window = app.get_window("main").unwrap(); + window.open_devtools(); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` +We're gonna open devtools right away to see what is going on in our app. We need the tauri_http_plugin to make http calls, and generate_context reads our `tauri.conf.json` in the package in which its run. +<br><br> +We need an icon folder and an icon to build. +```sh +mkdir src-tauri/icons && curl https://raw.githubusercontent.com/tauri-apps/tauri/dev/examples/.icons/128x128.png > src-tauri/icons/icon.png +``` +set nightly +```sh +rustup override set nightly +``` +Then run +```sh +cargo leptos serve +``` +You should get +```sh +➜ lepto_tauri_from_scratch git:(main) ✗ cargo leptos serve + Finished dev [unoptimized + debuginfo] target(s) in 0.60s + Cargo finished cargo build --package=leptos_tauri_from_scratch --lib --target-dir=/Users/sam/Projects/lepto_tauri_from_scratch/target/front --target=wasm32-unknown-unknown --no-default-features --features=hydrate + Front compiling WASM + Finished dev [unoptimized + debuginfo] target(s) in 0.93s + Cargo finished cargo build --package=leptos_tauri_from_scratch --bin=leptos_tauri_from_scratch_bin --no-default-features --features=ssr + Serving at http://0.0.0.0:3000 +listening on http://0.0.0.0:3000 +``` +Now open a new terminal and +```sh +cargo tauri build +``` +It'll build with csr before +```sh +Running beforeBuildCommand `trunk build --no-default-features -v --features "csr"` +``` +and then you should have your app, I'm on macOS so here's what I get. It's for desktop. +``` + Compiling src_tauri v0.0.1 (/Users/sam/Projects/lepto_tauri_from_scratch/src-tauri) + Finished release [optimized] target(s) in 2m 26s + Bundling leptos_tauri_from_scratch.app (/Users/sam/Projects/lepto_tauri_from_scratch/target/release/bundle/macos/leptos_tauri_from_scratch.app) + Bundling leptos_tauri_from_scratch_0.1.0_x64.dmg (/Users/sam/Projects/lepto_tauri_from_scratch/target/release/bundle/dmg/leptos_tauri_from_scratch_0.1.0_x64.dmg) + Running bundle_dmg.sh +``` +Open run it and voilá. Click hello world button and read "Hey" from the server. + + +## 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! \ No newline at end of file diff --git a/projects/tauri-from-scratch/Trunk.toml b/projects/tauri-from-scratch/Trunk.toml new file mode 100644 index 000000000..1231e6378 --- /dev/null +++ b/projects/tauri-from-scratch/Trunk.toml @@ -0,0 +1,5 @@ +[build] +target = "./src-orig/index.html" + +[watch] +ignore = ["./src-tauri"] \ No newline at end of file diff --git a/projects/tauri-from-scratch/public/favicon.ico b/projects/tauri-from-scratch/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/projects/tauri-from-scratch/public/favicon.ico differ diff --git a/projects/tauri-from-scratch/src-orig/Cargo.toml b/projects/tauri-from-scratch/src-orig/Cargo.toml new file mode 100644 index 000000000..93e92dc09 --- /dev/null +++ b/projects/tauri-from-scratch/src-orig/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "leptos_tauri_from_scratch" +version = "0.1.0" +edition = "2021" + + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name="leptos_tauri_from_scratch_bin" +path="./src/main.rs" + +[dependencies] +axum = {version = "0.7.0", optional=true} +axum-macros = { version= "0.4.1", optional=true} +cfg-if = "1.0.0" +console_error_panic_hook = "0.1.7" +console_log = "1.0.0" +leptos = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +leptos-use = "0.9.0" +leptos_axum = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6", optional = true } +leptos_meta = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +leptos_router = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +log = "0.4.20" +sqlx = { version = "0.7.3", optional = true, features= ["sqlite", "runtime-tokio"] } +serde = "1.0.195" +serde_json = "1.0.111" +server_fn = { git = "https://github.com/leptos-rs/leptos.git", branch = "leptos_v0.6" } +tokio = { version = "1.35.1", optional=true } +tower = {version = "0.4.10", optional = true} +tower-http = { version = "0.5.1", optional = true, features= ["fs","cors"] } +wasm-bindgen = "0.2.89" + +[features] +csr = [ "leptos/csr","leptos_meta/csr","leptos_router/csr", ] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = [ + "dep:axum", + "dep:axum-macros", + "leptos/ssr", + "leptos-use/ssr", + "dep:leptos_axum", + "leptos_meta/ssr", + "leptos_router/ssr", + "dep:tower-http", + "dep:tower", + "dep:sqlx", + "dep:tokio", +] + +[package.metadata.leptos] +bin-exe-name="leptos_tauri_from_scratch_bin" +output-name="leptos_tauri_from_scratch" +assets-dir = "../public" +site-pkg-dir = "pkg" +site-root = "target/site" +site-addr = "0.0.0.0:3000" +reload-port = 3001 +browserquery = "defaults" +watch = false +env = "DEV" +bin-features = ["ssr"] +bin-default-features = false +lib-features = ["hydrate"] +lib-default-features = false \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-orig/index.html b/projects/tauri-from-scratch/src-orig/index.html new file mode 100644 index 000000000..bd235f465 --- /dev/null +++ b/projects/tauri-from-scratch/src-orig/index.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <link data-trunk rel="rust" data-wasm-opt="z" data-bin="leptos_tauri_from_scratch_bin"/> + <link rel="icon" type="image/x-icon" href="favicon.ico"> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + </head> + <body></body> +</html> \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-orig/src/fallback.rs b/projects/tauri-from-scratch/src-orig/src/fallback.rs new file mode 100644 index 000000000..d89836c0d --- /dev/null +++ b/projects/tauri-from-scratch/src-orig/src/fallback.rs @@ -0,0 +1,39 @@ +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(); + 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(), move || view! {404}); + 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(); + 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}"), + )), + } +} \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-orig/src/lib.rs b/projects/tauri-from-scratch/src-orig/src/lib.rs new file mode 100644 index 000000000..e97d3824f --- /dev/null +++ b/projects/tauri-from-scratch/src-orig/src/lib.rs @@ -0,0 +1,44 @@ +use leptos::*; + +#[cfg(feature = "ssr")] +pub mod fallback; + +#[server(endpoint = "hello_world")] +pub async fn hello_world_server() -> Result<String, ServerFnError> { + Ok("Hey.".to_string()) +} + +#[component] +pub fn App() -> impl IntoView { + let action = create_server_action::<HelloWorldServer>(); + let vals = create_rw_signal(String::new()); + create_effect(move |_| { + if let Some(resp) = action.value().get() { + match resp { + Ok(val) => vals.set(val), + Err(err) => vals.set(format!("{err:?}")), + } + } + }); + view! {<button + on:click=move |_| { + action.dispatch(HelloWorldServer{}); + } + >"Hello world."</button> + { + move || vals.get() + } + } +} +cfg_if::cfg_if! { + if #[cfg(feature = "hydrate")] { + use wasm_bindgen::prelude::wasm_bindgen; + + #[wasm_bindgen] + pub fn hydrate() { + #[cfg(debug_assertions)] + console_error_panic_hook::set_once(); + leptos::mount_to_body(App); + } + } +} \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-orig/src/main.rs b/projects/tauri-from-scratch/src-orig/src/main.rs new file mode 100644 index 000000000..c7705eea1 --- /dev/null +++ b/projects/tauri-from-scratch/src-orig/src/main.rs @@ -0,0 +1,93 @@ +cfg_if::cfg_if! { + if #[cfg(feature="ssr")] { + use tower_http::cors::{CorsLayer}; + use axum::{ + Router, + routing::get, + extract::State, + http::Request, + body::Body, + response::IntoResponse + }; + use leptos::{*,provide_context, LeptosOptions}; + use leptos_axum::LeptosRoutes; + use leptos_tauri_from_scratch::fallback::file_and_error_handler; + + #[derive(Clone,Debug,axum_macros::FromRef)] + pub struct ServerState{ + pub options:LeptosOptions, + pub routes: Vec<leptos_router::RouteListing>, + } + + pub async fn server_fn_handler( + State(state): State<ServerState>, + request: Request<Body>, + ) -> impl IntoResponse { + leptos_axum::handle_server_fns_with_context( + move || { + provide_context(state.clone()); + }, + request, + ) + .await + .into_response() + } + + pub async fn leptos_routes_handler( + State(state): State<ServerState>, + req: Request<Body>, + ) -> axum::response::Response { + let handler = leptos_axum::render_route_with_context( + state.options.clone(), + state.routes.clone(), + move || { + provide_context("..."); + }, + leptos_tauri_from_scratch::App, + ); + handler(req).await.into_response() + } + + #[tokio::main] + async fn main() { + let conf = get_configuration(Some("./src-orig/Cargo.toml")).await.unwrap(); + + let leptos_options = conf.leptos_options; + let addr = leptos_options.site_addr; + let routes = leptos_axum::generate_route_list(leptos_tauri_from_scratch::App); + + let state = ServerState{ + options:leptos_options, + routes:routes.clone(), + }; + + let cors = CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin("tauri://localhost".parse::<axum::http::HeaderValue>().unwrap()) + .allow_headers(vec![axum::http::header::CONTENT_TYPE]); + + + let app = Router::new() + .route("/api/*fn_name",get(server_fn_handler).post(server_fn_handler)) + .layer(cors) + .leptos_routes_with_handler(routes, get(leptos_routes_handler)) + .fallback(file_and_error_handler) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + logging::log!("listening on http://{}", &addr); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + } + } else if #[cfg(feature="csr")]{ + pub fn main() { + server_fn::client::set_server_url("http://127.0.0.1:3000"); + leptos::mount_to_body(leptos_tauri_from_scratch::App); + } + } else { + pub fn main() { + + } + } +} \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-tauri/Cargo.toml b/projects/tauri-from-scratch/src-tauri/Cargo.toml new file mode 100644 index 000000000..90460e01b --- /dev/null +++ b/projects/tauri-from-scratch/src-tauri/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "src_tauri" +version = "0.0.1" +edition = "2021" + +[lib] +name="app_lib" +path="src/lib.rs" + +[build-dependencies] +tauri-build = { version = "2.0.0-alpha.13", features = [] } + +[dependencies] +log = "0.4.0" +serde = { version = "1.0", features = ["derive"] } +tauri = { version = "2.0.0-alpha.20", features = ["devtools"] } +tauri-plugin-http = "2.0.0-alpha.9" + +[features] +#default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/projects/tauri-from-scratch/src-tauri/build.rs b/projects/tauri-from-scratch/src-tauri/build.rs new file mode 100644 index 000000000..261851f6b --- /dev/null +++ b/projects/tauri-from-scratch/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/projects/tauri-from-scratch/src-tauri/icons/icon.png b/projects/tauri-from-scratch/src-tauri/icons/icon.png new file mode 100644 index 000000000..77e7d2338 Binary files /dev/null and b/projects/tauri-from-scratch/src-tauri/icons/icon.png differ diff --git a/projects/tauri-from-scratch/src-tauri/src/lib.rs b/projects/tauri-from-scratch/src-tauri/src/lib.rs new file mode 100644 index 000000000..94be001c7 --- /dev/null +++ b/projects/tauri-from-scratch/src-tauri/src/lib.rs @@ -0,0 +1,16 @@ +use tauri::Manager; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .setup(|app| { + { + let window = app.get_window("main").unwrap(); + window.open_devtools(); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-tauri/src/main.rs b/projects/tauri-from-scratch/src-tauri/src/main.rs new file mode 100644 index 000000000..21936f303 --- /dev/null +++ b/projects/tauri-from-scratch/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} \ No newline at end of file diff --git a/projects/tauri-from-scratch/src-tauri/tauri.conf.json b/projects/tauri-from-scratch/src-tauri/tauri.conf.json new file mode 100644 index 000000000..3859d5a94 --- /dev/null +++ b/projects/tauri-from-scratch/src-tauri/tauri.conf.json @@ -0,0 +1,54 @@ +{ + "build": { + "beforeDevCommand": "", + "beforeBuildCommand": "trunk build --no-default-features -v --features \"csr\"", + "devPath": "http://127.0.0.1:3000", + "distDir": "../src-orig/dist" + }, + "package": { + "productName": "leptos_tauri_from_scratch", + "version": "0.1.0" + }, + "tauri": { + "bundle": { + "active": true, + "category": "DeveloperTool", + "copyright": "", + "deb": { + "depends": [] + }, + "externalBin": [], + "icon": [], + "identifier": "leptos.chat.app", + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null + }, + "resources": [], + "shortDescription": "", + "targets": "all", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "height": 800, + "resizable": true, + "title": "LeptosChatApp", + "width": 1200 + } + ] + } + } + \ No newline at end of file