Repository: AppFlowy-IO/AppFlowy-Server Branch: main Commit: 9ad9ce584771 Files: 905 Total size: 4.0 MB Directory structure: gitextract_v4_lgx2l/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── audit.yml │ ├── build_test_docker_image.yaml │ ├── client_api_check.yml │ ├── commitlint.yml │ ├── integration_test.yml │ ├── push_latest_docker.yml │ ├── rustlint.yml │ ├── stress_test.yml │ ├── wasm_publish.yml │ └── web_docker.yml ├── .gitignore ├── .sqlx/ │ ├── query-0389af6b225125d09c5a75b443561dba4d97b786d040e5b8d5a76de36716beb2.json │ ├── query-05e89f62ff993fa2e4b0002c0022bba9706359e402b07b15ccdeb67492625064.json │ ├── query-06096ba1131e78d3da5df25a4b0a1193f11c9782abaf91faf263a116f90e51af.json │ ├── query-075b89cfe2572d28e7adfc29bbe52fef4afdd5013686f7294efd966739886f0d.json │ ├── query-0781735c56d22370302beec06863dccbbb9e664b212de93e5073508a82b91609.json │ ├── query-081abcd7f80664e8acd205833b0f9ca43bc1ccc03d992e7b1c45c3e401a6007a.json │ ├── query-084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3.json │ ├── query-09cf032adce81ba99362b3df50ba104f4e1eb2d538350c65cf615ea13f1c37f0.json │ ├── query-09ff850490eab213cfa0ad88ece9ce7baa39beabee19754fd993268d29552eb9.json │ ├── query-0affbd65859d6299c6ba736797f970b86552b83d95316ec3f54f93501e00b522.json │ ├── query-0d9c62acb33b96bb81536d1ad3121174403bcd40b777eb8d384fe8e81e1db3c4.json │ ├── query-0eeb2af3c6974c7e6d1c20bb4b08965eae9b0a291c7cef6451208b7740b9804c.json │ ├── query-12c52797d87c0ec56ffe6d8baf24501a276fdac4453399190dc221de89b611f8.json │ ├── query-1545a42d784a1a5fa8e9ed6128814608b9230b64ce23dcd85de444a7aa01bf9e.json │ ├── query-15613595695e2e722c45712931ce0eb8d2a3deb1bb665d1f091f354a3ad96b92.json │ ├── query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json │ ├── query-18207c125d5f974894576ee1dcfe406b221e9119f570403ec7a41ae1359b3f6c.json │ ├── query-1ae2809504bb6ea7dabcb5b5acfed09b0dd2e382e9fec3430680192df63876b8.json │ ├── query-1b1ff4352abb6dad982279ee99c8dccb3621b55a838998c1b9803982ae10f622.json │ ├── query-1bd79541a2b351b11ae94fe8a7aad408f9b563fd123099aa701a1e07ce797d2f.json │ ├── query-1c8f022ff5add11376dbbc17efd874dd31fd908c4f17be1bded18dbc689e3b36.json │ ├── query-1e36d9b3adf957524af88f997f12e5eeeaabda218c3709540e4a4c2df0180047.json │ ├── query-21195760ea7ed2dc4eda1dc2bd0eed9afcc63651ba6e67e7db675307e3b87821.json │ ├── query-2167ca10f5c560d8d4121d57d425c84482fa1dd52ee6f2cc7934e7d356b0dee6.json │ ├── query-21f66ca39be3377f8c5e4b218123e266fe8e03260ecd1891c644820892dda2b2.json │ ├── query-223e530f8605f6d00789344565666f57705151e3c2318519e877b22f8ffc871b.json │ ├── query-229a99b7a3a2f136babd5499c2a1047fe840903acf0d06e57fb78ca9b03e7008.json │ ├── query-2394226650959b34ae80b1948b7a111720b3ea5da48934d8d7e395ecc84e6985.json │ ├── query-24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716.json │ ├── query-2593b975fcf2dcf0129a1390fd8e2888d440e07c904d7eb3ca14957be8bc6069.json │ ├── query-2902fd3a9faa9481754d38b29abb543640c0b5564dca8f0141c7de2b8aab9551.json │ ├── query-291f0916b7868f3598b50f659689b9c77d34112c2a2fff9fc04775da9f97e46d.json │ ├── query-29279a0a97beb08aea84d588374c7534c28bd9c4da24b1ee20245109f5c33880.json │ ├── query-2b0754f55889a20c294d2a77ba8d3fa34c8174856abfdede34797851183a177a.json │ ├── query-2c0a776a787bc748857873b682d2fa3c549ffeaf767aa8ee05b09b3857505ded.json │ ├── query-2c496e29533dd27117fbb688ba2324f04d7cc306181fcf3f82079d5639f632c4.json │ ├── query-2d6d00669ea7d598d69d848d143f33e8c144d35b3d4c5293f98344b2c62fe6c8.json │ ├── query-2dda0bc4d9486a49c0af00d8ee4408c970a2ba3533217c130281e7db5a4e3d6b.json │ ├── query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json │ ├── query-315840e0657ea0b8d162635b4cc21ce84a09fd7ea14ea07980869a80ee06900c.json │ ├── query-32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7.json │ ├── query-340b8cef5a7676541b86505cdf103fcb5b54c40a9d6e599dc1d9dc0a95e1e862.json │ ├── query-354166a6fa147dc6e17bfc14cb68d3a72a2e7c3aa2d115686deb12086786e034.json │ ├── query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json │ ├── query-36733444fc8fac851fb540105ea6c9dca785455ae44ae518b98d8b57082e11d8.json │ ├── query-3865d921d76ac0d0eb16065738cddf82cb71945504116b0a04da759209b9c250.json │ ├── query-3b2daf263b4022e69c819edb55d412da8ad3fe4377155d8485fbaf186069f389.json │ ├── query-3bb5b82d46c55bbfd51319310a3cd065c4b796462a1ddf3c17617ee65ce9961a.json │ ├── query-3c2c94b9ac0a329b92847d7176a7435f894c5ef3b3b11e3e2ae03a8ec454a6d8.json │ ├── query-3ca587826f0598e7786c765dcb2fcd6ae08d8aa404f02920307547c769a3f91b.json │ ├── query-3cfb0a6d9a798f29422bc4bf4a52d3c86c3aae98c173b83c60eb57504a3d2c7c.json │ ├── query-3d3309a4ae7a88b3f7c9608dd78a1c1dc9b237a37e29722bcd2910bd23f9d873.json │ ├── query-3fdd28c263edf5c91ab8b770e6106d4890ec4bae2ff3c20f80c40cb4042d9e03.json │ ├── query-40db0a61665bdb9f7e9d1ce2a6c0eb05703e36e83c87802a72630388588de8cd.json │ ├── query-4123fa8796e8b56225155f79c2ee4c4dacda5ef51e858ce7dcb9877c7d55bd53.json │ ├── query-425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6.json │ ├── query-441316f35ca8c24bf78167f9fec48e28c05969bbbbe3d0e3d9e1569a375de476.json │ ├── query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json │ ├── query-44e4be501db0375fbd8ad8ed923bef887e361fe466ab46bdd6663f6cf97413a8.json │ ├── query-4f5951e61713d04963524b84648c9ff8c7be05f0089f6fd26fc6e0e0afeae579.json │ ├── query-4fc0611c846f86be652d42eb8ae21a5da0353fe810856aaabe91d7963329d098.json │ ├── query-51a3a723b1825da7b9abd9cb36db0cf8220abf063098a73e4a6fc3f87352b395.json │ ├── query-523087b0101a35abfc70a561272acec7a357491a86901f7927b8242173b5c8c8.json │ ├── query-52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04.json │ ├── query-53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb.json │ ├── query-594af4041e0778476a699536316007f0a264f7d3db9de6326ef8082a2a898995.json │ ├── query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json │ ├── query-59b2a7854bb8f0d7ee34b9dfa4e3db5cac8e25fdebe186ba2cbd65012eb91f5f.json │ ├── query-5c2d58bfdedbb1be71337a97d5ed5a2921f83dd549507b2834a4d2582d2c361b.json │ ├── query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json │ ├── query-5d408d36790ade4da1ceeb68b4a183aa7d9abc27b0ec42c2a3c5af26ad80f128.json │ ├── query-5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557.json │ ├── query-5e0d58f612425e1cf36dfc7f56691cfb8f6def1a3d29645922cb437d11ce62ef.json │ ├── query-620167841bb2acdd1c9c6aadf8245e3a483d87dc006d4e361e994ce2c5d768cd.json │ ├── query-62ed61bcf92fc0c3756f57d0fe05cdd12e70072f5646fe48790ad189a6e96b12.json │ ├── query-6380f5a6ded2dab8f18de42541c9d77c2f3af512e3f66e1b731ca7c00c9ea8f8.json │ ├── query-63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f.json │ ├── query-66218110851919b05b95b008a17547547d23f6baeeff8a5521b2b246126adc34.json │ ├── query-6716ec4787f7155af97a4890730f4b3fe564ead8d99f8355ac249f9b39316238.json │ ├── query-67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129.json │ ├── query-6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553.json │ ├── query-6935572cb23700243fbbd3dc382cdbf56edaadc4aab7855c237bce68e29414c0.json │ ├── query-6aca3fde126cb1761c0a5ce1fbfa793bdbac4aed137cdf60eb3f277f36d7bf7a.json │ ├── query-6ca2a2fa10d5334183d98176998d41f36948fe5624e290a32d0b50bc9fb256bf.json │ ├── query-6cc4a7da11a37413c9951983ee3f30de933cc6357a66c8e10366fde27acaefea.json │ ├── query-6f5d6d79587d7f7a52c920acccfe338a8c001ea30b722d3a6a1a60259d47913c.json │ ├── query-6fbcd1c32c638530461c74f8c8195a5b1e1e6f7a389a6a60d889c88c5f47302a.json │ ├── query-71c15686124c05a4fdef066738eadd0ab17d6af1bfeffc480c8fe52a4e6edab8.json │ ├── query-74de473589a405c3ab567e72a881869321095e2de497b2c1866c547f939c359c.json │ ├── query-75dc8578510ae696bf4bcdd780f7cefc666b4436cf53edf30a98dd2ff7926799.json │ ├── query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json │ ├── query-786a59b28265397658aecf0318beeedece2a7f5bea80b9189f3989721035c593.json │ ├── query-78a191e21a7e7a07eee88ed02c7fbf7035f908b8e4057f7ace1b3b5d433424fe.json │ ├── query-794c4ced16801b3e98a62eb44c18c14137dd09b11be73442a7f46b2f938b8445.json │ ├── query-7a4c7da16e99ff3875bdd7e0d189e26c3c1ab49672bace41992aecc446061850.json │ ├── query-7a86f93afe6e77d4481920b08ed38926446f6473107d68dfcd82ffecddcee890.json │ ├── query-7aa6e41c80f0b2906d46e73ae05e8e70e133b7edd450b102715b8a487d6055ac.json │ ├── query-7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22.json │ ├── query-811b6b01de4fdb06ad58185a5c49dfaa31aef8ea30ab3421d4afc13822fc0a9c.json │ ├── query-816a026ca4c25329b2fb24d59efde9ab71798ff8b31ce7320e02344d4e8b3e42.json │ ├── query-834638eb3c38eb2c220aa23ac928874d87606b47ef3bb80540614ce2f8453936.json │ ├── query-842243ea6ca59135ae539060ff37b80791e76aa268a44642ede515f315e80c01.json │ ├── query-84c224af99f654e2e0ba11a411376794855483eedb0c30b1873ed660ca8d10cd.json │ ├── query-84e600f13d61c56a45133e7458d5152e68dec72030e5789bf4149a333b6ebdf5.json │ ├── query-852c729791d5b5eb2dde5772ccbcd24579486e43886d95a11481991fdf28efa8.json │ ├── query-85e9688218913dee85480932273ff6cf75d29af45638b195e73d73b6048806bf.json │ ├── query-865fe86df6d04f8abb6d477af13f8a2392a742f4027d99c290f0f156df48be07.json │ ├── query-87628d6739441a22229d08832d09cbf4598c36204a6885b2e279c848cedcfa75.json │ ├── query-88516b9a2a424bc7697337d6f16b0d6e94b919597d709f930467423c5b4c0ec2.json │ ├── query-8cd79c307813a509119230c7673f86471463a06ad9a84764da8d5bb1e6168e1c.json │ ├── query-90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de.json │ ├── query-90afca9cc8b6d4ca31e8ddf1ce466411b5034639df91b739f5cbe2af0ffb6811.json │ ├── query-92c4d0e22b1f6f117c9f19589832f5f89cb5b903eee3c12f5e5fc0f70f3236e1.json │ ├── query-936faba4e3c8fc3685d68f561a2c2d4f386c77cffde6f25702c19758a12669ce.json │ ├── query-93f6a59171d7cd08d321c777f24255621280fbcf6a2c009afd601eac16c9ba3a.json │ ├── query-94555a25b986992bd3cfb67bd36ff015d39bdd78ac20d56570306616bf10faf3.json │ ├── query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json │ ├── query-95b4d7508569cac38c78d21a0a471772d3703e5678ee7ca0cd32d60f5343be91.json │ ├── query-95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12.json │ ├── query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json │ ├── query-9b2a8297fa991418b255fc5cb6ad70d695c4dceed20bdc557bfedfc820511126.json │ ├── query-a18d0c9536dba734715903c8e8f0b7be30d3e7a477c4ddd03533b781df2fb2c7.json │ ├── query-a3ab30d48e4a10aff1fbfa9dbc5d275a06598610bc471893c8c0febfc36c4737.json │ ├── query-a3c235bd5df50f80ec93c3d9f6da8db7e17e89788f30c5b6432c582992b6a009.json │ ├── query-a527a90fcb69c58a5e711555b6ee56e7b92ceabe746279eccd7ae3e9fa918e96.json │ ├── query-a75bf8b11d832d154716d4618595b117da583a31b51baaf7b84e9ee0d0e3109c.json │ ├── query-a7c03becdf9954611ac7ad96e1f5bb5e8364f095f1cc4dc23719b218eb032973.json │ ├── query-aa75996ca6aa12f0bcaa5fb092ac279f8a94aadcc29d0e2b652dc420506835e7.json │ ├── query-b16f38d563d4d0b35f06978a8b2c76dc5121b0e59f8b5992c9dad05dd101c8ad.json │ ├── query-b5024138772e13557df973c1c021daf74aab97b5874d7366c478c18ae2e89e58.json │ ├── query-b509712055858af398fd12ddd1a8c3da54280cf55f0c53f340bddbf4bf09b3e0.json │ ├── query-ba815f67aab3f302a2982225b72c6113bbd9bc87326e4f0a3b44dadbb5f47920.json │ ├── query-bbb3c31ea7e9c0a3bdabbc23b2730ee0254f38a7c1457f917c8f37f1e1aefa12.json │ ├── query-bd34e351ea1adc0d12d4f1cce5a855089b7f39a431dea2903c3e0b9a220640b8.json │ ├── query-bde2b88ffb1b59362c7ae82369892c79131c175924f95e5d48d75931fb846f41.json │ ├── query-bf9bff5c65ba051329ed2b694eff62808f971a8262b6e1649d91526ab3a3870d.json │ ├── query-c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e.json │ ├── query-c360ec37792d567535ccd2a5011d92c7a201f516e92e204db855167f381c58b1.json │ ├── query-c43d414f6fcaed34e059f55abaaa0bd1343cacf4d04e98481a4787a4b965ce94.json │ ├── query-c81848346ed2ff85f1d5fb8041fba648137a927762b385b97054552c00793a50.json │ ├── query-c843fb8517b1e364016b85a9e94927673bf8311bfbf723b610d59ecfef3fafce.json │ ├── query-c8b1f57c5ddce8006a8e137be07f13b455f59657f5fcef67d69905ecec4cb063.json │ ├── query-ca2a21db67716e3f12b9f9240c1dba1b7cbe0bec1f59ef132fed53942ebad317.json │ ├── query-cb2375ad0094baefed417645b781f40dcabfbfe4a4738c99bb4efff649e6a0e6.json │ ├── query-cbe8402053d42529dce158b446d09a00982e1d7cdc33835776bfbefb4b4c1854.json │ ├── query-cbf1d3d9fdeb672eacd4b008879787bc1f0b22a554fb249d4e12a665d9767cbd.json │ ├── query-cce2abeed3399ad0b8867901735c5883c8d35fa82d6e0596c56eaf02c36a7e4f.json │ ├── query-d0a24b554fe420d7ebf856ae7f1525aff3695fc97e2f43041dc54a4e62a88746.json │ ├── query-d0e5f5097b35a15f19e9e7faf2c62336d5f130e939331e84c7d834f6028ea673.json │ ├── query-d1ab621e0b6e8bc24f8fa8cbb975ae3b7f9f366cac02d66b5291d7207295ca29.json │ ├── query-d1f845717b19636e61d1d96d7a5629754f3ded9bda9116953bd1b40bd80551ae.json │ ├── query-d2e87c077e5702cd57a88e23e1eabe4b0badd98ef99da1b185bffa8d5c9ed298.json │ ├── query-d366aca6b187f086e5a8281081adec190bbb3cd5256c5a77ed321b99cd34bbbc.json │ ├── query-d388782f755f0b164ef36c168af142baeb9bbd3cc2b8b7cd736b346580be8790.json │ ├── query-d492c20dec54c7335744dcc139b95f30a80f06d9fd48de644630adf183e1ac34.json │ ├── query-d4fa2c5f3c455be4694235009e82efdd99d366e3b0374f78efec8dd560f88d95.json │ ├── query-d61523de25986b47a382d36a1f18e590420f1b1285d024f5554cc02c375d6476.json │ ├── query-d756ec630d5b75dd0dc7df2339847e28bdf07a790e65fd40a64d7f9022f430bd.json │ ├── query-d84ab58e78653688e7c392ffad00d6e039be5ccb9c5b99b7088cc41cfe981873.json │ ├── query-d90e7efaca54b92de038b6eef20a7bd36be747dc38f7943fe299799c623038be.json │ ├── query-d921f52e4bc3fef72c810e19455a2fa4fbd52f5a1f3a1838b146d001eadabd47.json │ ├── query-da1434fe116cbb48bc5aac0b6905dd748f096bf78d3cdcfea3a576b4aaeba5fc.json │ ├── query-dbc31936b3e79632f9c8bae449182274d9d75766bd9a5c383b96bd60e9c5c866.json │ ├── query-dc600fc160b55be22fb77e285fd7e5e646ef359fdbca9b62c6aefede5ebff606.json │ ├── query-e219696c80f1d4c38260ebeb50ec78e344975eef6760951dbf6201c01b8ceef0.json │ ├── query-e2b4d66736962d1e3d0b9cf687ce5c5e653b465462f53433a28cf314e5c87d6c.json │ ├── query-e38e66d89806471f358b317778de35a68da4b9e6ca6e4b6a7c437ca7493b858c.json │ ├── query-e6159a03f1521b44de59858cd95c48e62cabefba6cac629c104eec75d2868bf3.json │ ├── query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json │ ├── query-ea239353f73904400915ec89640ac71985a8d5b39037f567a3e2ac1c5eea8f64.json │ ├── query-eb142b33bd6d0d9f3ceb597be9251eac710a463d1052ba10c41b207dbf63efe1.json │ ├── query-ed9bce7f35c4dd8d41427bc56db67adf175044a8d31149b3745ceb8f9b3c82fa.json │ ├── query-ef947984b00fdd32271e7e76d8b5d035cd4ca211b600787fda18d62a34b4c04b.json │ ├── query-f05042dd22f862603e63f63d47b93e579545c79cabe15d32304a47ca7665a55f.json │ ├── query-f18d6e075a522b0ce5935351dd57ab0dda4d8b4ed3881c2ad0bc09c07c43e6fe.json │ ├── query-f409626142553d4496d15b5dfa7da8a5a238da86f56c930c09a261f2efa1f55c.json │ ├── query-f54ced785b4fdd22c9236b566996d5d9d4a8c91902e4029fe8f8f30f3af39b39.json │ ├── query-f58a2f05efbda0698d27d83be5c6816fc46e3de33f926c6343bcbfa90a387b07.json │ ├── query-f68cc2042d6aa78feeb33640e9ef13f46c5e10ee269ea0bd965b0e57dee6cf94.json │ ├── query-f78c2c56568dcee0b93e759ee517fb87d6d115a02856a756d481ea4c863c0327.json │ ├── query-f9c28d0fa124ef543259c6869d7c517deabda3af9a67c6e59d8e15c0245c83a0.json │ ├── query-fa92aff963d9a0c69fb203f76f54728c67d52a68eada59ba3bd445c4b8aeceef.json │ ├── query-faf37892741717680e9a8d8e7d8decaba571d0dd129b57334aad7c63e2a2ef59.json │ ├── query-fb21df2827de97055cdc1c493b079b29667f75b18169c909c4c8341697fd0105.json │ ├── query-fd2a37dd917717a9bb5e1db84f03f0e84e32d2fd081955389561c6567896ea9f.json │ └── query-fffe6f01abf0e5d8649a49b5793ccb92a9f823f07c363341357ea74bf4f4a16d.json ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SELF_HOST_LICENSE_AGREEMENT.md ├── admin_frontend/ │ ├── Cargo.toml │ ├── Dockerfile │ ├── README.md │ ├── assets/ │ │ ├── README.md │ │ ├── apple/ │ │ │ └── logo.html │ │ ├── base.css │ │ ├── discord/ │ │ │ ├── README.md │ │ │ └── logo.html │ │ ├── github/ │ │ │ ├── README.md │ │ │ └── logo.html │ │ ├── google/ │ │ │ ├── README.md │ │ │ ├── logo.css │ │ │ └── logo.html │ │ ├── home.css │ │ ├── login.css │ │ ├── logo.html │ │ ├── message.css │ │ ├── minio/ │ │ │ └── logo.html │ │ ├── navigate.css │ │ ├── postgres/ │ │ │ └── logo.html │ │ ├── sidebar.css │ │ └── top_menu_bar.css │ ├── dev.env │ ├── src/ │ │ ├── askama_entities.rs │ │ ├── config.rs │ │ ├── error.rs │ │ ├── ext/ │ │ │ ├── api.rs │ │ │ ├── entities.rs │ │ │ ├── error.rs │ │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── models.rs │ │ ├── response.rs │ │ ├── session.rs │ │ ├── templates.rs │ │ ├── web_api.rs │ │ └── web_app.rs │ ├── templates/ │ │ ├── components/ │ │ │ ├── admin_navigate.html │ │ │ ├── admin_sidebar.html │ │ │ ├── admin_sso_create.html │ │ │ ├── admin_sso_detail.html │ │ │ ├── admin_sso_list.html │ │ │ ├── admin_top_menu_bar.html │ │ │ ├── admin_user_details.html │ │ │ ├── admin_users.html │ │ │ ├── appflowy_banner.html │ │ │ ├── change_password.html │ │ │ ├── create_user.html │ │ │ ├── invite.html │ │ │ ├── message.html │ │ │ ├── navigate.html │ │ │ ├── shared_workspaces.html │ │ │ ├── sidebar.html │ │ │ ├── top_menu_bar.html │ │ │ ├── user_details.html │ │ │ ├── user_usage.html │ │ │ └── workspace_usage.html │ │ ├── layouts/ │ │ │ └── base.html │ │ └── pages/ │ │ ├── admin_home.html │ │ ├── home.html │ │ ├── login.html │ │ ├── login_callback.html │ │ ├── login_v2.html │ │ ├── open_appflowy_or_download.html │ │ ├── payment_success_redirect.html │ │ └── redirect.html │ └── tests/ │ ├── main.rs │ ├── oauth/ │ │ └── mod.rs │ └── utils/ │ ├── mod.rs │ └── test_config.rs ├── assets/ │ └── mailer_templates/ │ ├── build_production/ │ │ ├── access_request.html │ │ ├── access_request_approved_notification.html │ │ ├── confirmation.html │ │ ├── import_data_fail.html │ │ ├── import_data_success.html │ │ ├── magic_link.html │ │ ├── page_mention_notification.html │ │ ├── recovery.html │ │ └── workspace_invitation.html │ └── confirmation.html ├── deny.toml ├── deploy.env ├── dev.env ├── doc/ │ ├── AUTHENTICATION.md │ ├── CONTRIBUTING.md │ ├── DEPLOYMENT.md │ ├── EC2_SELF_HOST_GUIDE.md │ ├── GUIDE.md │ ├── LOCAL_BUILD.md │ ├── OKTA_SAML.md │ └── README.md ├── docker/ │ ├── gotrue/ │ │ ├── Dockerfile │ │ └── start.sh │ ├── pgadmin/ │ │ └── servers.json │ └── web/ │ ├── Dockerfile │ └── nginx.conf ├── docker-compose-ci.yml ├── docker-compose-dev.yml ├── docker-compose-extras.yml ├── docker-compose.yml ├── email_template/ │ ├── .editorconfig │ ├── .gitignore │ ├── .npmrc │ ├── LICENSE │ ├── README.md │ ├── config.js │ ├── config.production.js │ ├── package.json │ ├── src/ │ │ ├── components/ │ │ │ ├── button.html │ │ │ ├── divider.html │ │ │ ├── footer.html │ │ │ ├── spacer.html │ │ │ ├── v-fill.html │ │ │ └── v-image.html │ │ ├── css/ │ │ │ ├── resets.css │ │ │ ├── tailwind.css │ │ │ └── utilities.css │ │ ├── layouts/ │ │ │ └── main.html │ │ └── templates/ │ │ ├── access_request.html │ │ ├── access_request_approved_notification.html │ │ ├── confirmation.html │ │ ├── import_data_fail.html │ │ ├── import_data_success.html │ │ ├── magic_link.html │ │ ├── page_mention_notification.html │ │ ├── recovery.html │ │ └── workspace_invitation.html │ └── tailwind.config.js ├── env.deploy.secret.example ├── env.dev.secret.example ├── external_proxy_config/ │ └── nginx/ │ └── appflowy.site.conf ├── libs/ │ ├── access-control/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── act.rs │ │ ├── casbin/ │ │ │ ├── access.rs │ │ │ ├── adapter.rs │ │ │ ├── collab.rs │ │ │ ├── enforcer.rs │ │ │ ├── enforcer_v2.rs │ │ │ ├── mod.rs │ │ │ ├── performance_comparison_tests.rs │ │ │ ├── redis_cache.rs │ │ │ ├── util.rs │ │ │ └── workspace.rs │ │ ├── collab.rs │ │ ├── entity.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── noops/ │ │ │ ├── collab.rs │ │ │ ├── mod.rs │ │ │ └── workspace.rs │ │ ├── request.rs │ │ └── workspace.rs │ ├── app-error/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── gotrue.rs │ │ └── lib.rs │ ├── appflowy-ai-client/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── client.rs │ │ │ ├── dto.rs │ │ │ ├── error.rs │ │ │ └── lib.rs │ │ └── tests/ │ │ ├── chat_test/ │ │ │ ├── completion_test.rs │ │ │ ├── context_test.rs │ │ │ ├── mod.rs │ │ │ ├── model_config_test.rs │ │ │ └── qa_test.rs │ │ ├── index_test/ │ │ │ ├── index_search_test.rs │ │ │ └── mod.rs │ │ ├── main.rs │ │ └── row_test/ │ │ ├── mod.rs │ │ ├── summarize_test.rs │ │ └── translate_test.rs │ ├── appflowy-proto/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── proto/ │ │ │ ├── collab.proto │ │ │ ├── messages.proto │ │ │ └── notification.proto │ │ └── src/ │ │ ├── client_message.rs │ │ ├── lib.rs │ │ ├── pb/ │ │ │ ├── collab.rs │ │ │ ├── messages.rs │ │ │ ├── mod.rs │ │ │ └── notification.rs │ │ ├── server_message.rs │ │ └── shared.rs │ ├── client-api/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── collab_sync/ │ │ │ ├── collab_sink.rs │ │ │ ├── collab_stream.rs │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ ├── plugin.rs │ │ │ └── sync_control.rs │ │ ├── http.rs │ │ ├── http_access_request.rs │ │ ├── http_ai.rs │ │ ├── http_billing.rs │ │ ├── http_blob.rs │ │ ├── http_chat.rs │ │ ├── http_collab.rs │ │ ├── http_file.rs │ │ ├── http_guest.rs │ │ ├── http_member.rs │ │ ├── http_person.rs │ │ ├── http_publish.rs │ │ ├── http_quick_note.rs │ │ ├── http_search.rs │ │ ├── http_settings.rs │ │ ├── http_template.rs │ │ ├── http_view.rs │ │ ├── lib.rs │ │ ├── log.rs │ │ ├── notify.rs │ │ ├── ping.rs │ │ ├── retry.rs │ │ ├── v2/ │ │ │ ├── PROTOCOL.md │ │ │ ├── actor.rs │ │ │ ├── compactor.rs │ │ │ ├── conn_retry.rs │ │ │ ├── controller.rs │ │ │ ├── db.rs │ │ │ └── mod.rs │ │ └── ws/ │ │ ├── client.rs │ │ ├── error.rs │ │ ├── handler.rs │ │ ├── mod.rs │ │ ├── msg_queue.rs │ │ └── state.rs │ ├── client-api-entity/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── id.rs │ │ └── lib.rs │ ├── client-api-test/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── assertion_utils.rs │ │ ├── async_utils.rs │ │ ├── client.rs │ │ ├── database_util.rs │ │ ├── lib.rs │ │ ├── log.rs │ │ ├── test_client.rs │ │ ├── test_client_config.rs │ │ ├── test_client_v2.rs │ │ ├── user.rs │ │ └── workspace_ops.rs │ ├── client-websocket/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── message.rs │ │ ├── native.rs │ │ └── web.rs │ ├── collab-rt-entity/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── migration/ │ │ │ ├── 0147/ │ │ │ │ ├── client_init │ │ │ │ └── collab_update │ │ │ └── 0149/ │ │ │ ├── client_collab_v1 │ │ │ ├── client_init │ │ │ └── collab_update │ │ ├── proto/ │ │ │ ├── collab.proto │ │ │ └── realtime.proto │ │ ├── src/ │ │ │ ├── client_message.rs │ │ │ ├── collab_proto.rs │ │ │ ├── lib.rs │ │ │ ├── message.rs │ │ │ ├── realtime_proto.rs │ │ │ ├── server_message.rs │ │ │ └── user.rs │ │ └── tests/ │ │ ├── main.rs │ │ └── serde_test.rs │ ├── collab-rt-protocol/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── data_validation.rs │ │ ├── lib.rs │ │ ├── message.rs │ │ └── protocol.rs │ ├── collab-stream/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── awareness_gossip.rs │ │ ├── client.rs │ │ ├── collab_update_sink.rs │ │ ├── error.rs │ │ ├── lease.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── model.rs │ │ └── stream_router.rs │ ├── database/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── access_request.rs │ │ ├── chat/ │ │ │ ├── chat_ops.rs │ │ │ └── mod.rs │ │ ├── collab/ │ │ │ ├── collab_db_ops.rs │ │ │ ├── collab_storage.rs │ │ │ └── mod.rs │ │ ├── file/ │ │ │ ├── file_storage.rs │ │ │ ├── mod.rs │ │ │ ├── s3_client_impl.rs │ │ │ └── utils.rs │ │ ├── history/ │ │ │ ├── mod.rs │ │ │ └── ops.rs │ │ ├── index/ │ │ │ ├── collab_embeddings_ops.rs │ │ │ ├── mod.rs │ │ │ └── search_ops.rs │ │ ├── lib.rs │ │ ├── listener.rs │ │ ├── notification.rs │ │ ├── pg_row.rs │ │ ├── publish.rs │ │ ├── quick_note.rs │ │ ├── resource_usage.rs │ │ ├── template.rs │ │ ├── user.rs │ │ └── workspace.rs │ ├── database-entity/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── dto.rs │ │ ├── error.rs │ │ ├── file_dto.rs │ │ └── lib.rs │ ├── gotrue/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── api.rs │ │ ├── grant.rs │ │ ├── lib.rs │ │ └── params.rs │ ├── gotrue-entity/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── dto.rs │ │ ├── gotrue_jwt.rs │ │ ├── lib.rs │ │ └── sso.rs │ ├── indexer/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ ├── collab_indexer/ │ │ │ ├── document_indexer.rs │ │ │ ├── mod.rs │ │ │ └── provider.rs │ │ ├── entity.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── metrics.rs │ │ ├── queue.rs │ │ ├── scheduler.rs │ │ ├── unindexed_workspace.rs │ │ └── vector/ │ │ ├── embedder.rs │ │ ├── mod.rs │ │ └── open_ai.rs │ ├── infra/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── env_util.rs │ │ ├── file_util.rs │ │ ├── lib.rs │ │ ├── reqwest.rs │ │ ├── thread_pool.rs │ │ ├── tokio_runtime.rs │ │ └── validate.rs │ ├── llm-client/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── chat.rs │ │ └── lib.rs │ ├── mailer/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── config.rs │ │ ├── lib.rs │ │ └── sender.rs │ ├── shared-entity/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── dto/ │ │ │ ├── access_request_dto.rs │ │ │ ├── ai_dto.rs │ │ │ ├── auth_dto.rs │ │ │ ├── billing_dto.rs │ │ │ ├── chat_dto.rs │ │ │ ├── file_dto.rs │ │ │ ├── guest_dto.rs │ │ │ ├── history_dto.rs │ │ │ ├── import_dto.rs │ │ │ ├── mod.rs │ │ │ ├── publish_dto.rs │ │ │ ├── search_dto.rs │ │ │ ├── server_info_dto.rs │ │ │ └── workspace_dto.rs │ │ ├── lib.rs │ │ ├── request.rs │ │ ├── response.rs │ │ ├── response_actix.rs │ │ └── response_stream.rs │ ├── snowflake/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── tonic-proto/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── proto/ │ │ │ └── history.proto │ │ └── src/ │ │ └── lib.rs │ └── workspace-template/ │ ├── Cargo.toml │ ├── assets/ │ │ ├── default_space.json │ │ ├── desktop_guide.json │ │ ├── getting_started.json │ │ ├── inbox.json │ │ ├── initial_document.json │ │ ├── mobile_guide.json │ │ ├── to-dos.json │ │ ├── vault_get_started.json │ │ └── web_guide.json │ └── src/ │ ├── database/ │ │ ├── database_collab.rs │ │ └── mod.rs │ ├── document/ │ │ ├── getting_started.rs │ │ ├── mod.rs │ │ ├── parser.rs │ │ ├── util.rs │ │ └── vault_template.rs │ ├── hierarchy_builder.rs │ ├── lib.rs │ └── tests/ │ ├── getting_started_tests.rs │ └── mod.rs ├── migrations/ │ ├── 20230312043024_user.sql │ ├── 20230906101032_permission.sql │ ├── 20230906101223_workspace.sql │ ├── 20230906101555_user_profile.sql │ ├── 20230906102652_collab.sql │ ├── 20230926145155_blob_storage.sql │ ├── 20231113074418_user_change.sql │ ├── 20231130150001_user_id_foreign_key.sql │ ├── 20240123140707_workspace_owner_trigger.sql │ ├── 20240227000000_workspace_icon.sql │ ├── 20240303003711_collab_member_timestamp.sql │ ├── 20240304173938_workspace_invitation.sql │ ├── 20240306110000_workspace_invitation_2.sql │ ├── 20240412083446_history_init.sql │ ├── 20240510024506_chat_message.sql │ ├── 20240529054858_workspace_add_token_usage.sql │ ├── 20240531031836_chat_message_meta.sql │ ├── 20240604090043_add_workspace_settings.sql │ ├── 20240613112820_publish_collab.sql │ ├── 20240614171931_collab_embeddings.sql │ ├── 20240617135926_af_workspace_foreign_key_indices.sql │ ├── 20240618035048_af_workspace_ai_usage.sql │ ├── 20240618173348_publish_collab_2.sql │ ├── 20240621105148_publish_collab_3.sql │ ├── 20240626184736_publish_collab_4.sql │ ├── 20240627525836_publish_collab_5.sql │ ├── 20240629035230_publish_collab_6.sql │ ├── 20240630010030_workspace_member_foreign_key.sql │ ├── 20240723090305_publish_view_comment.sql │ ├── 20240725065111_publish_view_reaction.sql │ ├── 20240729065107_publish_view_reaction_2.sql │ ├── 20240806054557_template_category.sql │ ├── 20240806103039_template_creator.sql │ ├── 20240813040905_template.sql │ ├── 20240910100000_af_collab_embeddings_indices.sql │ ├── 20240924045045_access_request.sql │ ├── 20240930135712_import_data.sql │ ├── 20241014153023_default_published_view.sql │ ├── 20241025135939_import_task_add_uid_column.sql │ ├── 20241031094508_af_uuid_indexes.sql │ ├── 20241101063559_af_workspace_namespace.sql │ ├── 20241108155841_unpublished_collab.sql │ ├── 20241124212630_af_collab_updated_at.sql │ ├── 20241126175909_af_collab_stored_procedures.sql │ ├── 20241211034455_stop_writing_to_collab_member.sql │ ├── 20241216080018_quick_notes.sql │ ├── 20241218090459_collab_embedding_add_metadata.sql │ ├── 20241222152427_collab_add_indexed_at.sql │ ├── 20241230064618_collab_embedding_add_fragment_index.sql │ ├── 20250109142738_blob_metadata_add_file_status.sql │ ├── 20250113091708_publish_options.sql │ ├── 20250217080054_drop_collab_member_trigger.sql │ ├── 20250226091933_blob_metadata_add_file_source.sql │ ├── 20250305082546_workspace_delete_trigger.sql │ ├── 20250318120849_departition_af_collab.sql │ ├── 20250403021559_workspace_invite_code.sql │ ├── 20250405092732_af_collab_embeddings_upsert.sql │ ├── 20250414074846_drop_af_collab_set_updated_at_trigger.sql │ ├── 20250703030740_workspace_member_profile.sql │ ├── 20250714060306_page_mention.sql │ ├── 20250718033221_page_mention_notification.sql │ ├── 20250721084910_page_mention_view_name.sql │ ├── 20250723024109_workspace_profile_custom_image_url.sql │ └── 20250723072011_page_mention_notification_status.sql ├── nginx/ │ ├── nginx.conf │ └── ssl/ │ ├── certificate.crt │ └── private_key.key ├── rust-toolchain.toml ├── rustfmt.toml ├── script/ │ ├── client_api_deps_check.sh │ ├── code_gen.sh │ ├── diagnose_appflowy.sh │ ├── generate_env.sh │ ├── lib/ │ │ ├── README.md │ │ ├── check_config.sh │ │ ├── check_containers.sh │ │ ├── check_functional.sh │ │ ├── check_health.sh │ │ ├── check_logs.sh │ │ ├── report.sh │ │ └── utils.sh │ ├── redis/ │ │ ├── remove_redis_stream_range.sh │ │ └── show_redis_stream_values.sh │ ├── reset-password-interactive.sh │ ├── run_ci_server.sh │ └── run_local_server.sh ├── services/ │ ├── appflowy-collaborate/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── actix_ws/ │ │ │ │ ├── client/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── rt_client.rs │ │ │ │ ├── entities.rs │ │ │ │ ├── mod.rs │ │ │ │ └── server/ │ │ │ │ ├── mod.rs │ │ │ │ └── rt_actor.rs │ │ │ ├── client/ │ │ │ │ ├── client_msg_router.rs │ │ │ │ └── mod.rs │ │ │ ├── collab/ │ │ │ │ ├── cache/ │ │ │ │ │ ├── collab_cache.rs │ │ │ │ │ ├── disk_cache.rs │ │ │ │ │ ├── mem_cache.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── collab_manager.rs │ │ │ │ ├── collab_store.rs │ │ │ │ ├── mod.rs │ │ │ │ └── snapshot_scheduler.rs │ │ │ ├── compression.rs │ │ │ ├── config.rs │ │ │ ├── connect_state.rs │ │ │ ├── error.rs │ │ │ ├── group/ │ │ │ │ ├── cmd.rs │ │ │ │ ├── group_init.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── null_sender.rs │ │ │ │ └── state.rs │ │ │ ├── lib.rs │ │ │ ├── metrics.rs │ │ │ ├── permission.rs │ │ │ ├── rt_server.rs │ │ │ ├── snapshot/ │ │ │ │ ├── mod.rs │ │ │ │ └── snapshot_control.rs │ │ │ ├── util/ │ │ │ │ ├── channel_ext.rs │ │ │ │ └── mod.rs │ │ │ └── ws2/ │ │ │ ├── actors/ │ │ │ │ ├── mod.rs │ │ │ │ ├── server.rs │ │ │ │ ├── session.rs │ │ │ │ └── workspace.rs │ │ │ └── mod.rs │ │ └── tests/ │ │ ├── indexer_test.rs │ │ └── main.rs │ └── appflowy-worker/ │ ├── Cargo.toml │ ├── Dockerfile │ ├── README.md │ ├── deploy.env │ ├── src/ │ │ ├── application.rs │ │ ├── config.rs │ │ ├── error.rs │ │ ├── import_worker/ │ │ │ ├── email_notifier.rs │ │ │ ├── mod.rs │ │ │ ├── report.rs │ │ │ └── worker.rs │ │ ├── indexer_worker/ │ │ │ ├── mod.rs │ │ │ └── worker.rs │ │ ├── lib.rs │ │ ├── mailer.rs │ │ ├── main.rs │ │ ├── metric.rs │ │ └── s3_client.rs │ └── tests/ │ ├── import_test.rs │ └── main.rs ├── src/ │ ├── api/ │ │ ├── access_request.rs │ │ ├── ai.rs │ │ ├── chat.rs │ │ ├── data_import.rs │ │ ├── file_storage.rs │ │ ├── guest.rs │ │ ├── invite_code.rs │ │ ├── metrics.rs │ │ ├── mod.rs │ │ ├── search.rs │ │ ├── server_info.rs │ │ ├── template.rs │ │ ├── user.rs │ │ ├── util.rs │ │ ├── workspace.rs │ │ └── ws.rs │ ├── application.rs │ ├── biz/ │ │ ├── access_request/ │ │ │ ├── mod.rs │ │ │ └── ops.rs │ │ ├── authentication/ │ │ │ ├── jwt.rs │ │ │ └── mod.rs │ │ ├── chat/ │ │ │ ├── metrics.rs │ │ │ ├── mod.rs │ │ │ └── ops.rs │ │ ├── collab/ │ │ │ ├── database.rs │ │ │ ├── folder_view.rs │ │ │ ├── mod.rs │ │ │ ├── ops.rs │ │ │ ├── publish_outline.rs │ │ │ └── utils.rs │ │ ├── data_import/ │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── notification/ │ │ │ ├── email.rs │ │ │ └── mod.rs │ │ ├── pg_listener.rs │ │ ├── search/ │ │ │ ├── mod.rs │ │ │ └── ops.rs │ │ ├── template/ │ │ │ ├── mod.rs │ │ │ └── ops.rs │ │ ├── user/ │ │ │ ├── image_asset.rs │ │ │ ├── mod.rs │ │ │ ├── user_delete.rs │ │ │ ├── user_info.rs │ │ │ ├── user_init.rs │ │ │ └── user_verify.rs │ │ └── workspace/ │ │ ├── duplicate.rs │ │ ├── invite.rs │ │ ├── mod.rs │ │ ├── ops.rs │ │ ├── page_view.rs │ │ ├── publish.rs │ │ ├── publish_dup.rs │ │ └── quick_note.rs │ ├── config/ │ │ ├── config.rs │ │ └── mod.rs │ ├── domain/ │ │ ├── compression.rs │ │ └── mod.rs │ ├── lib.rs │ ├── mailer.rs │ ├── main.rs │ ├── middleware/ │ │ ├── metrics_mw.rs │ │ ├── mod.rs │ │ └── request_id.rs │ ├── state.rs │ └── telemetry.rs ├── tests/ │ ├── ai_test/ │ │ ├── asset/ │ │ │ └── my_profile.txt │ │ ├── chat_test.rs │ │ ├── chat_with_selected_doc_test.rs │ │ ├── completion_test.rs │ │ ├── mod.rs │ │ ├── summarize_row.rs │ │ ├── summary_search_test.rs │ │ └── util.rs │ ├── collab/ │ │ ├── awareness_test.rs │ │ ├── collab_curd_test.rs │ │ ├── collab_embedding_test.rs │ │ ├── database_crud.rs │ │ ├── missing_update_test.rs │ │ ├── mod.rs │ │ ├── multi_devices_edit.rs │ │ ├── permission_test.rs │ │ ├── single_device_edit.rs │ │ ├── storage_test.rs │ │ ├── stress_test.rs │ │ ├── util.rs │ │ └── web_edit.rs │ ├── collab_history/ │ │ ├── document_history.rs │ │ └── mod.rs │ ├── file_test/ │ │ ├── delete_dir_test.rs │ │ ├── mod.rs │ │ ├── multiple_part_test.rs │ │ ├── put_and_get.rs │ │ └── usage.rs │ ├── gotrue/ │ │ ├── admin.rs │ │ ├── health.rs │ │ ├── mod.rs │ │ └── settings.rs │ ├── main.rs │ ├── search/ │ │ ├── asset/ │ │ │ ├── appflowy_values.md │ │ │ ├── kathryn_tennis_story.md │ │ │ └── the_five_dysfunctions_of_a_team.md │ │ ├── document_search.rs │ │ └── mod.rs │ ├── server_info/ │ │ ├── info.rs │ │ └── mod.rs │ ├── sql_test/ │ │ ├── chat_test.rs │ │ ├── collab_embed_test.rs │ │ ├── history_test.rs │ │ ├── mod.rs │ │ ├── util.rs │ │ └── workspace_test.rs │ ├── user/ │ │ ├── delete.rs │ │ ├── image.rs │ │ ├── mod.rs │ │ ├── refresh.rs │ │ ├── sign_in.rs │ │ ├── sign_out.rs │ │ ├── sign_up.rs │ │ ├── update.rs │ │ └── user_awareness_test.rs │ ├── websocket/ │ │ ├── actor_test.rs │ │ ├── conn_test.rs │ │ └── mod.rs │ ├── workspace/ │ │ ├── access_request.rs │ │ ├── asset/ │ │ │ └── read_me.json │ │ ├── default_user_workspace.rs │ │ ├── edit_workspace.rs │ │ ├── import_test.rs │ │ ├── invitation_crud.rs │ │ ├── join_workspace.rs │ │ ├── member_crud.rs │ │ ├── mod.rs │ │ ├── page_view.rs │ │ ├── person.rs │ │ ├── publish.rs │ │ ├── published_data.rs │ │ ├── quick_note.rs │ │ ├── template.rs │ │ ├── workspace_crud.rs │ │ ├── workspace_folder.rs │ │ └── workspace_settings.rs │ └── yrs_version/ │ ├── README.md │ ├── document_test.rs │ ├── files/ │ │ ├── folder_encode_collab_0172 │ │ └── get_started_encode_collab_0172 │ ├── folder_test.rs │ ├── mod.rs │ └── util.rs └── xtask/ ├── Cargo.toml └── src/ └── main.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .env .dockerignore spec.yaml **/target target/ deploy/ tests/ docker/Dockerfile scripts/ ================================================ FILE: .github/FUNDING.yml ================================================ ko_fi: appflowy ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "[Bug] Untitled Bug Issue" labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Additional context** - Environment [e.g. flutter doctor -v or rustup show] Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[FR] Untitled Feature Request" labels: '' assignees: '' --- **1~3 main use cases of the proposed feature** Ex: As a ... , I want to set a reminder for a checkbox item so that I can be reminded by the system at a specific time. **what types of users can benefit from using your proposed feature** Ex: busy students who tend to forget their paper deadlines **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/audit.yml ================================================ name: Security audit on: schedule: - cron: '0 0 * * *' push: paths: - '**/Cargo.toml' - '**/Cargo.lock' jobs: security_audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: taiki-e/install-action@cargo-deny - name: Scan for vulnerabilities run: cargo deny check advisories ================================================ FILE: .github/workflows/build_test_docker_image.yaml ================================================ name: Manually Build Selected Docker Images on: workflow_dispatch: inputs: branch: description: "Branch to build from" required: true default: "main" debug_version: description: "Enter the release version tag (e.g. 0.9.30). The built image will be tagged as xxx:0.9.30-amd64." required: true archs: description: "Target architectures (comma separated), e.g. linux/amd64,linux/arm64" required: false default: "linux/amd64" build_gotrue: description: "Build GoTrue image" type: boolean required: false default: false build_appflowy_cloud: description: "Build AppFlowy Cloud image" type: boolean required: false default: true build_admin_frontend: description: "Build Admin Frontend image" type: boolean required: false default: false build_appflowy_worker: description: "Build AppFlowy Worker image" type: boolean required: false default: true jobs: setup: runs-on: ubuntu-22.04 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Set up architecture matrix id: set-matrix run: | # Convert comma-separated archs to JSON array and extract arch names archs="${{ github.event.inputs.archs }}" # Replace linux/ prefix and convert to JSON array matrix_archs=$(echo "$archs" | sed 's/linux\///g' | sed 's/,/","/g' | sed 's/^/["/' | sed 's/$/"]/') echo "matrix={\"arch\":$matrix_archs}" >> $GITHUB_OUTPUT echo "Generated matrix: {\"arch\":$matrix_archs}" gotrue: runs-on: ubuntu-22.04 needs: setup if: ${{ github.event.inputs.build_gotrue == 'true' }} strategy: matrix: ${{ fromJson(needs.setup.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.branch }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push GoTrue image uses: docker/build-push-action@v5 with: context: . file: ./docker/gotrue/Dockerfile platforms: linux/${{ matrix.arch }} push: true tags: | appflowyinc/gotrue:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal - name: Logout from Docker Hub if: always() run: docker logout appflowy_cloud: runs-on: ubuntu-22.04 needs: setup if: ${{ github.event.inputs.build_appflowy_cloud == 'true' }} strategy: matrix: ${{ fromJson(needs.setup.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.branch }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push AppFlowy Cloud image uses: docker/build-push-action@v5 with: context: . platforms: linux/${{ matrix.arch }} push: true tags: | ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_cloud:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal provenance: false build-args: | PROFILE=release - name: Logout from Docker Hub if: always() run: docker logout admin_frontend: runs-on: ubuntu-22.04 needs: setup if: ${{ github.event.inputs.build_admin_frontend == 'true' }} strategy: matrix: ${{ fromJson(needs.setup.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.branch }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push Admin Frontend image uses: docker/build-push-action@v5 with: context: . file: ./admin_frontend/Dockerfile platforms: linux/${{ matrix.arch }} push: true tags: | ${{ secrets.DOCKER_HUB_USERNAME }}/admin_frontend:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal provenance: false build-args: | PROFILE=release - name: Logout from Docker Hub if: always() run: docker logout appflowy_worker: runs-on: ubuntu-22.04 needs: setup if: ${{ github.event.inputs.build_appflowy_worker == 'true' }} strategy: matrix: ${{ fromJson(needs.setup.outputs.matrix) }} steps: - name: Checkout repository uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.branch }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push AppFlowy Worker image uses: docker/build-push-action@v5 with: context: . file: ./services/appflowy-worker/Dockerfile platforms: linux/${{ matrix.arch }} push: true tags: | ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_worker:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal provenance: false build-args: | PROFILE=release - name: Logout from Docker Hub if: always() run: docker logout ================================================ FILE: .github/workflows/client_api_check.yml ================================================ name: ClientAPI Check on: push: branches: [ main ] pull_request: types: [ opened, synchronize, reopened ] branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: workspaces: | AppFlowy-Cloud - name: Install cargo-tree run: cargo install cargo-tree - name: Install wasm-pack run: cargo install wasm-pack - name: install prerequisites run: | sudo apt-get update sudo apt-get install protobuf-compiler - name: Build ClientAPI working-directory: ./libs/client-api run: cargo build --features "enable_brotli" - name: Check ClientAPI Dependencies working-directory: ./libs/client-api run: bash ../../script/client_api_deps_check.sh ================================================ FILE: .github/workflows/commitlint.yml ================================================ name: Lint Commit Messages on: [pull_request, push] jobs: commitlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v4 ================================================ FILE: .github/workflows/integration_test.yml ================================================ name: AppFlowy-Cloud Integrations on: push: branches: [ main ] paths: - 'src/**' - 'libs/**' - 'services/**' - 'admin_frontend/**' pull_request: branches: [ main ] paths: - 'src/**' - 'libs/**' - 'services/**' - 'admin_frontend/**' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: LOCALHOST_URL: http://localhost LOCALHOST_WS: ws://localhost/ws/v1 LOCALHOST_WS_V2: ws://localhost/ws/v2 APPFLOWY_REDIS_URI: redis://redis:6379 APPFLOWY_AI_REDIS_URL: redis://redis:6379 LOCALHOST_GOTRUE: http://localhost/gotrue POSTGRES_PASSWORD: password DATABASE_URL: postgres://postgres:password@localhost:5432/postgres SQLX_OFFLINE: true RUST_TOOLCHAIN: "1.86.0" APPFLOWY_AI_VERSION: "0.9.38-amd64" jobs: setup: name: Setup Environment and Build Images runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install prerequisites run: | sudo apt-get update sudo apt-get install protobuf-compiler sudo update-ca-certificates - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: driver-opts: network=host - name: Build Docker Images run: | export DOCKER_DEFAULT_PLATFORM=linux/amd64 cp deploy.env .env docker compose build \ --parallel \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg PROFILE=debug \ appflowy_cloud appflowy_worker admin_frontend - name: Save Docker Images run: | docker save appflowyinc/appflowy_cloud:latest | gzip > appflowy_cloud.tar.gz docker save appflowyinc/appflowy_worker:latest | gzip > appflowy_worker.tar.gz docker save appflowyinc/admin_frontend:latest | gzip > admin_frontend.tar.gz - name: Upload Docker Images as Artifacts uses: actions/upload-artifact@v4 with: name: docker-images path: | appflowy_cloud.tar.gz appflowy_worker.tar.gz admin_frontend.tar.gz retention-days: 1 test: name: Integration Tests runs-on: ubuntu-latest needs: setup timeout-minutes: 60 strategy: matrix: include: - test_service: "appflowy_cloud" test_cmd: "--workspace --exclude appflowy-ai-client --features ai-test-enabled" - test_service: "appflowy_cloud_new_sync" test_cmd: "--features sync-v2 --test main collab" - test_service: "appflowy_worker" test_cmd: "-p appflowy-worker" - test_service: "admin_frontend" test_cmd: "-p admin_frontend" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: toolchain: ${{ env.RUST_TOOLCHAIN }} workspaces: "AppFlowy-Cloud" - name: Download Docker Images uses: actions/download-artifact@v4 with: name: docker-images - name: Load Docker Images run: | docker load < appflowy_cloud.tar.gz docker load < appflowy_worker.tar.gz docker load < admin_frontend.tar.gz - name: Copy and rename deploy.env to .env run: cp deploy.env .env - name: Replace values in .env run: | # log level sed -i 's|RUST_LOG=.*|RUST_LOG='appflowy_cloud=trace,appflowy_worker=trace,database=trace,indexer=trace'|' .env sed -i 's|GOTRUE_SMTP_USER=.*|GOTRUE_SMTP_USER=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env sed -i 's|GOTRUE_SMTP_PASS=.*|GOTRUE_SMTP_PASS=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env sed -i 's|GOTRUE_SMTP_ADMIN_EMAIL=.*|GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.CI_GOTRUE_SMTP_ADMIN_EMAIL }}|' .env sed -i 's|GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*|GOTRUE_EXTERNAL_GOOGLE_ENABLED=true|' .env sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=false|' .env sed -i 's|API_EXTERNAL_URL=http://your-host/gotrue|API_EXTERNAL_URL=http://localhost/gotrue|' .env sed -i 's|GOTRUE_RATE_LIMIT_EMAIL_SENT=100|GOTRUE_RATE_LIMIT_EMAIL_SENT=1000|' .env sed -i 's|APPFLOWY_MAILER_SMTP_USERNAME=.*|APPFLOWY_MAILER_SMTP_USERNAME=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env sed -i 's|APPFLOWY_MAILER_SMTP_PASSWORD=.*|APPFLOWY_MAILER_SMTP_PASSWORD=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env sed -i 's|AI_OPENAI_API_KEY=.*|AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env sed -i 's|AI_OPENAI_API_SUMMARY_MODEL=.*|AI_OPENAI_API_SUMMARY_MODEL="gpt-4o-mini"|' .env sed -i 's|APPFLOWY_EMBEDDING_CHUNK_SIZE=.*|APPFLOWY_EMBEDDING_CHUNK_SIZE=500|' .env sed -i 's|APPFLOWY_EMBEDDING_CHUNK_OVERLAP=.*|APPFLOWY_EMBEDDING_CHUNK_OVERLAP=50|' .env sed -i 's|AI_ANTHROPIC_API_KEY=.*|AI_ANTHROPIC_API_KEY=${{ secrets.CI_AI_ANTHROPIC_API_KEY }}|' .env sed -i 's|AI_APPFLOWY_HOST=.*|AI_APPFLOWY_HOST=http://localhost|' .env sed -i 's|APPFLOWY_WEB_URL=.*|APPFLOWY_WEB_URL=http://localhost:3000|' .env sed -i 's|.*APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=.*|APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=http://localhost/minio-api|' .env shell: bash - name: Update Nginx Configuration # the wasm-pack headless tests will run on random ports, so we need to allow all origins run: sed -i 's/http:\/\/127\.0\.0\.1:8000/http:\/\/127.0.0.1/g' nginx/nginx.conf - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Run Docker-Compose run: | export APPFLOWY_WORKER_VERSION=latest export APPFLOWY_CLOUD_VERSION=latest export APPFLOWY_ADMIN_FRONTEND_VERSION=latest export APPFLOWY_AI_VERSION=${{ env.APPFLOWY_AI_VERSION }} docker compose -f docker-compose-ci.yml up -d docker ps -a - name: Wait for services to be ready run: | echo "Waiting for services to be ready..." timeout 300 bash -c 'until curl -f http://localhost/health 2>/dev/null; do sleep 5; done' || echo "Health check timeout - proceeding anyway" - name: Install prerequisites run: | sudo apt-get update sudo apt-get install -y protobuf-compiler - name: Run Tests run: | echo "Running tests for ${{ matrix.test_service }} with flags: ${{ matrix.test_cmd }}" RUST_LOG="info" DISABLE_CI_TEST_LOG="true" cargo test ${{ matrix.test_cmd }} -- --skip stress_test - name: Server Logs if: failure() run: | docker ps -a docker compose -f docker-compose-ci.yml logs - name: AI Logs if: failure() run: | docker logs appflowy-cloud-ai-1 ================================================ FILE: .github/workflows/push_latest_docker.yml ================================================ name: DockerHub Build and Push #`DOCKER_HUB_USERNAME` is the username you use to log in to Docker Hub at https://hub.docker.com/. It's your Docker Hub # account username. #`DOCKER_HUB_ACCESS_TOKEN` is a security token that you should create in your Docker Hub account settings, specifically # under "account settings / security." This token should be generated with read and write access permissions to Docker # Hub repositories. It allows you to authenticate and interact with Docker Hub programmatically, such as pushing and pulling Docker images or making API requests. on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+*' # Trigger for tags like v1.2.3 - '[0-9]+.[0-9]+.[0-9]+*' # Trigger for tags like 1.2.3 or 1.2.3-alpha env: CARGO_TERM_COLOR: always LATEST_TAG: latest jobs: gotrue_image: runs-on: ubuntu-22.04 steps: - name: Check out the repository uses: actions/checkout@v3 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and Push GoTrue run: | export TAG=${GITHUB_REF#refs/*/} docker buildx build --platform linux/amd64,linux/arm64 -t appflowyinc/gotrue:${TAG} -t appflowyinc/gotrue:latest -f docker/gotrue/Dockerfile --push docker/gotrue appflowy_cloud_image: runs-on: ${{ matrix.job.os }} env: IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_cloud strategy: fail-fast: false matrix: job: - { os: "ubuntu-22.04", name: "amd64", docker_platform: "linux/amd64" } - { os: "ubuntu-22.04-arm", name: "arm64v8", docker_platform: "linux/arm64" } steps: - name: Check out the repository uses: actions/checkout@v2 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Extract metadata id: meta uses: docker/metadata-action@v4 with: images: registry.hub.docker.com/${{ env.IMAGE_NAME }} - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }} uses: docker/build-push-action@v5 with: platforms: ${{ matrix.job.docker_platform }} push: true tags: | ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} labels: ${{ steps.meta.outputs.labels }} provenance: false build-args: | PROFILE=release FEATURES= - name: Logout from Docker Hub if: always() run: docker logout appflowy_cloud_docker_manifest: runs-on: ubuntu-22.04 needs: [ appflowy_cloud_image ] strategy: fail-fast: false matrix: job: - { image_name: "appflowy_cloud" } steps: - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Create and push manifest for ${{ matrix.job.image_name }}:version uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8 push: true - name: Create and push manifest for ${{ matrix.job.image_name }}:latest uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8 push: true - name: Logout from Docker Hub if: always() run: docker logout admin_frontend_image: runs-on: ${{ matrix.job.os }} env: IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/admin_frontend strategy: fail-fast: false matrix: job: - { os: "ubuntu-22.04", name: "amd64", docker_platform: "linux/amd64" } - { os: "ubuntu-22.04-arm", name: "arm64v8", docker_platform: "linux/arm64" } steps: - name: Check out the repository uses: actions/checkout@v2 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Extract metadata id: meta uses: docker/metadata-action@v4 with: images: registry.hub.docker.com/${{ env.IMAGE_NAME }} - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }} uses: docker/build-push-action@v5 with: platforms: ${{ matrix.job.docker_platform }} file: ./admin_frontend/Dockerfile push: true tags: | ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} labels: ${{ steps.meta.outputs.labels }} provenance: false build-args: | PROFILE=release - name: Logout from Docker Hub if: always() run: docker logout admin_frontend_docker_manifest: runs-on: ubuntu-22.04 needs: [ admin_frontend_image ] strategy: fail-fast: false matrix: job: - { image_name: "admin_frontend" } steps: - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Create and push manifest for ${{ matrix.job.image_name }}:version uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8 push: true - name: Create and push manifest for ${{ matrix.job.image_name }}:latest uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8 push: true - name: Logout from Docker Hub if: always() run: docker logout appflowy_worker_image: runs-on: ${{ matrix.job.os }} env: IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_worker strategy: fail-fast: false matrix: job: - { os: "ubuntu-22.04", name: "amd64", docker_platform: "linux/amd64" } - { os: "ubuntu-22.04-arm", name: "arm64v8", docker_platform: "linux/arm64" } steps: - name: Check out the repository uses: actions/checkout@v2 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Extract metadata id: meta uses: docker/metadata-action@v4 with: images: registry.hub.docker.com/${{ env.IMAGE_NAME }} - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }} uses: docker/build-push-action@v5 with: platforms: ${{ matrix.job.docker_platform }} file: ./services/appflowy-worker/Dockerfile push: true tags: | ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} labels: ${{ steps.meta.outputs.labels }} provenance: false - name: Logout from Docker Hub if: always() run: docker logout appflowy_worker_manifest: runs-on: ubuntu-22.04 needs: [ appflowy_worker_image ] strategy: fail-fast: false matrix: job: - { image_name: "appflowy_worker" } steps: - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Get git tag id: vars run: | T=${GITHUB_REF#refs/*/} # Remove "refs/*/" prefix from GITHUB_REF echo "GIT_TAG=$T" >> $GITHUB_ENV - name: Create and push manifest for ${{ matrix.job.image_name }}:version uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8 push: true - name: Create and push manifest for ${{ matrix.job.image_name }}:latest uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }} images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8 push: true - name: Logout from Docker Hub if: always() run: docker logout ================================================ FILE: .github/workflows/rustlint.yml ================================================ name: Lint on: push: branches: [ main ] pull_request: types: [ opened, synchronize, reopened ] branches: [ main ] env: SQLX_VERSION: 0.7.1 SQLX_FEATURES: "rustls,postgres" SQLX_OFFLINE: true RUST_TOOLCHAIN: "1.86.0" jobs: test: name: fmt & clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} override: true components: rustfmt, clippy profile: minimal - name: install prerequisites run: | sudo apt-get update sudo apt-get install protobuf-compiler - uses: Swatinem/rust-cache@v2 with: workspaces: | AppFlowy-Cloud key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-clippy- - name: Copy and rename dev.env to .env run: cp dev.env .env - name: Code Gen working-directory: ./script run: ./code_gen.sh - name: Rustfmt run: | cargo fmt --check - name: Clippy run: cargo clippy --all-targets --all-features --tests -- -D warnings ================================================ FILE: .github/workflows/stress_test.yml ================================================ name: AppFlowy-Cloud Stress Test on: [ pull_request ] concurrency: group: stress-test-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: SQLX_OFFLINE: true RUST_TOOLCHAIN: "1.86.0" POSTGRES_HOST: localhost REDIS_HOST: localhost LOCALHOST_GOTRUE: http://localhost/gotrue DATABASE_URL: postgres://postgres:password@localhost:5432/postgres jobs: test: name: Collab Stress Tests runs-on: self-hosted-appflowy3 steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Install Rust Toolchain uses: dtolnay/rust-toolchain@stable - name: Copy and Rename dev.env to .env run: cp dev.env .env - name: Install Prerequisites run: | brew update if ! brew list libpq &>/dev/null; then echo "Installing libpq..." brew install libpq else echo "libpq is already installed." fi if ! brew list sqlx-cli &>/dev/null; then echo "Installing sqlx-cli..." brew install sqlx-cli else echo "sqlx-cli is already installed." fi if ! brew list protobuf &>/dev/null; then echo "Installing protobuf..." brew install protobuf else echo "protobuf is already installed." fi - name: Replace Values in .env run: | sed -i '' 's|RUST_LOG=.*|RUST_LOG=debug|' .env sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost:9999|' .env sed -i '' 's|APPFLOWY_INDEXER_ENABLED=.*|APPFLOWY_INDEXER_ENABLED=false|' .env sed -i '' 's|APPFLOWY_GOTRUE_BASE_URL=.*|APPFLOWY_GOTRUE_BASE_URL=http://localhost:9999|' .env sed -i '' 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=false|' .env sed -i '' 's|APPFLOWY_DATABASE_URL=.*|APPFLOWY_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres|' .env cat .env shell: bash - name: Start Docker Compose Services run: | docker compose -f docker-compose-dev.yml down docker compose -f docker-compose-dev.yml up -d ./script/code_gen.sh cargo sqlx database create && cargo sqlx migrate run - name: Run Server and Test run: | cargo run --package xtask -- --stress-test ================================================ FILE: .github/workflows/wasm_publish.yml ================================================ name: Manual NPM Package Publish on: workflow_dispatch: inputs: working_directory: description: 'Working directory (e.g., libs/client-api-wasm)' required: true default: 'libs/client-api-wasm' package_name: description: 'Which package to publish' required: true default: '@appflowyinc/client-api-wasm' type: choice options: - '@appflowyinc/client-api-wasm' package_version: description: 'Package version' required: true env: NODE_VERSION: '20.12.0' RUST_TOOLCHAIN: "1.86.0" jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Setup Node.js uses: actions/setup-node@v1 with: node-version: ${{ env.NODE_VERSION }} - uses: Swatinem/rust-cache@v2 with: workspaces: | AppFlowy-Cloud - name: Install wasm-pack run: cargo install wasm-pack - name: Build with wasm-pack run: wasm-pack build --release working-directory: ${{ github.event.inputs.working_directory }} - name: Update name working-directory: ${{ github.event.inputs.working_directory }}/pkg run: | jq '.name = "${{ github.event.inputs.package_name }}"' package.json > package.json.tmp mv package.json.tmp package.json - name: Update version working-directory: ${{ github.event.inputs.working_directory }}/pkg run: | npm version ${{ github.event.inputs.package_version }} - name: Configure npm for wasm-pack run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ${{ github.event.inputs.working_directory }}/pkg/.npmrc - name: Publish package run: | npm config set access public wasm-pack publish working-directory: ${{ github.event.inputs.working_directory }}/pkg env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/web_docker.yml ================================================ name: AppFlowy Web image build and push on: workflow_dispatch: inputs: version: description: 'AppFlowy Web version' required: true env: IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_web jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: include: - os: ubuntu-24.04 platform: linux/amd64 - os: ubuntu-24.04-arm platform: linux/arm64 steps: - name: Prepare run: | PLATFORM=${{ matrix.platform }} VERSION=${{ github.event.inputs.version }} IMAGE_TAG=${VERSION#v} echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> $GITHUB_ENV echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV - name: Check out the repository uses: actions/checkout@v3 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push id: build uses: docker/build-push-action@v6 with: platforms: ${{ matrix.platform }} tags: | ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-${{ env.PLATFORM_PAIR }} ${{ env.IMAGE_NAME }}:latest-${{ env.PLATFORM_PAIR }} build-args: VERSION=${{ github.event.inputs.version }} context: docker/web provenance: false push: true merge: runs-on: ubuntu-24.04 needs: - build steps: - name: Prepare run: | VERSION=${{ github.event.inputs.version }} IMAGE_TAG=${VERSION#v} echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Create and push manifest uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} images: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-linux-amd64,${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-linux-arm64 push: true - name: Create and push manifest uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ env.IMAGE_NAME }}:latest images: ${{ env.IMAGE_NAME }}:latest-linux-amd64,${{ env.IMAGE_NAME }}:latest-linux-arm64 push: true ================================================ FILE: .gitignore ================================================ # Generated by Cargo # will have compiled files and executables **/target/ # These are backup files generated by rustfmt **/*.rs.bk .idea/ **/temp/** package-lock.json yarn.lock node_modules **/libs/AppFlowy-Collab/ data/ .env .logs flake.nix flake.lock .envrc .direnv/ .claude **/.DS_Store **/.env.* docker-compose.override.yml .serena ================================================ FILE: .sqlx/query-0389af6b225125d09c5a75b443561dba4d97b786d040e5b8d5a76de36716beb2.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid\n FROM af_collab\n WHERE workspace_id = $1\n AND partition_key = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false ] }, "hash": "0389af6b225125d09c5a75b443561dba4d97b786d040e5b8d5a76de36716beb2" } ================================================ FILE: .sqlx/query-05e89f62ff993fa2e4b0002c0022bba9706359e402b07b15ccdeb67492625064.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT updated_at, blob\n FROM af_collab\n WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 1, "name": "blob", "type_info": "Bytea" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false, false ] }, "hash": "05e89f62ff993fa2e4b0002c0022bba9706359e402b07b15ccdeb67492625064" } ================================================ FILE: .sqlx/query-06096ba1131e78d3da5df25a4b0a1193f11c9782abaf91faf263a116f90e51af.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\n SELECT * FROM UNNEST($1::uuid[], $2::bytea[], $3::int[], $4::int[], $5::bigint[], $6::uuid[], $7::timestamp with time zone[])\n ON CONFLICT (oid)\n DO UPDATE SET blob = excluded.blob, len = excluded.len, updated_at = excluded.updated_at where af_collab.workspace_id = excluded.workspace_id\n ", "describe": { "columns": [], "parameters": { "Left": [ "UuidArray", "ByteaArray", "Int4Array", "Int4Array", "Int8Array", "UuidArray", "TimestamptzArray" ] }, "nullable": [] }, "hash": "06096ba1131e78d3da5df25a4b0a1193f11c9782abaf91faf263a116f90e51af" } ================================================ FILE: .sqlx/query-075b89cfe2572d28e7adfc29bbe52fef4afdd5013686f7294efd966739886f0d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT email FROM af_user WHERE uid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "email", "type_info": "Text" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false ] }, "hash": "075b89cfe2572d28e7adfc29bbe52fef4afdd5013686f7294efd966739886f0d" } ================================================ FILE: .sqlx/query-0781735c56d22370302beec06863dccbbb9e664b212de93e5073508a82b91609.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace\n SET default_published_view_id = $1\n WHERE workspace_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "0781735c56d22370302beec06863dccbbb9e664b212de93e5073508a82b91609" } ================================================ FILE: .sqlx/query-081abcd7f80664e8acd205833b0f9ca43bc1ccc03d992e7b1c45c3e401a6007a.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n database_storage_id\n FROM public.af_workspace\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "database_storage_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "081abcd7f80664e8acd205833b0f9ca43bc1ccc03d992e7b1c45c3e401a6007a" } ================================================ FILE: .sqlx/query-084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_workspace_invite_code (workspace_id, invite_code, expires_at)\n VALUES ($1, $2, $3)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Timestamp" ] }, "nullable": [] }, "hash": "084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3" } ================================================ FILE: .sqlx/query-09cf032adce81ba99362b3df50ba104f4e1eb2d538350c65cf615ea13f1c37f0.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_template_view\n WHERE view_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "09cf032adce81ba99362b3df50ba104f4e1eb2d538350c65cf615ea13f1c37f0" } ================================================ FILE: .sqlx/query-09ff850490eab213cfa0ad88ece9ce7baa39beabee19754fd993268d29552eb9.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_chat_messages (chat_id, author, content, meta_data)\n VALUES ($1, $2, $3, $4)\n RETURNING message_id, created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "message_id", "type_info": "Int8" }, { "ordinal": 1, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Jsonb", "Text", "Jsonb" ] }, "nullable": [ false, false ] }, "hash": "09ff850490eab213cfa0ad88ece9ce7baa39beabee19754fd993268d29552eb9" } ================================================ FILE: .sqlx/query-0affbd65859d6299c6ba736797f970b86552b83d95316ec3f54f93501e00b522.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id\n FROM af_workspace\n WHERE owner_uid = (SELECT uid FROM public.af_user WHERE uuid = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "0affbd65859d6299c6ba736797f970b86552b83d95316ec3f54f93501e00b522" } ================================================ FILE: .sqlx/query-0d9c62acb33b96bb81536d1ad3121174403bcd40b777eb8d384fe8e81e1db3c4.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT invite_code\n FROM af_workspace_invite_code\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "invite_code", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "0d9c62acb33b96bb81536d1ad3121174403bcd40b777eb8d384fe8e81e1db3c4" } ================================================ FILE: .sqlx/query-0eeb2af3c6974c7e6d1c20bb4b08965eae9b0a291c7cef6451208b7740b9804c.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH last_mentioned AS (\n SELECT\n person_id,\n MAX(mentioned_at) AS last_mentioned_at\n FROM af_page_mention\n WHERE workspace_id = $1\n GROUP BY person_id\n )\n\n SELECT\n au.uuid,\n COALESCE(awmp.name, au.name) AS \"name!\",\n au.email,\n awm.role_id AS \"role!\",\n COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n awmp.cover_image_url,\n awmp.custom_image_url,\n awmp.description,\n lm.last_mentioned_at\n FROM af_workspace_member awm\n JOIN af_user au ON awm.uid = au.uid\n LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n LEFT JOIN last_mentioned lm ON au.uuid = lm.person_id\n WHERE awm.workspace_id = $1\n ORDER BY lm.last_mentioned_at DESC NULLS LAST\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "role!", "type_info": "Int4" }, { "ordinal": 4, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 5, "name": "cover_image_url", "type_info": "Text" }, { "ordinal": 6, "name": "custom_image_url", "type_info": "Text" }, { "ordinal": 7, "name": "description", "type_info": "Text" }, { "ordinal": 8, "name": "last_mentioned_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, null, false, false, null, true, true, true, null ] }, "hash": "0eeb2af3c6974c7e6d1c20bb4b08965eae9b0a291c7cef6451208b7740b9804c" } ================================================ FILE: .sqlx/query-12c52797d87c0ec56ffe6d8baf24501a276fdac4453399190dc221de89b611f8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, namespace, is_original\n FROM af_workspace_namespace\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "namespace", "type_info": "Text" }, { "ordinal": 2, "name": "is_original", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "12c52797d87c0ec56ffe6d8baf24501a276fdac4453399190dc221de89b611f8" } ================================================ FILE: .sqlx/query-1545a42d784a1a5fa8e9ed6128814608b9230b64ce23dcd85de444a7aa01bf9e.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n au.uuid,\n COALESCE(awmp.name, au.name) AS \"name!\",\n au.email,\n awm.role_id AS \"role!\",\n COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n awmp.cover_image_url,\n awmp.custom_image_url,\n awmp.description\n FROM af_workspace_member awm\n JOIN af_user au ON awm.uid = au.uid\n LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n WHERE awm.workspace_id = $1\n AND au.uuid = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "role!", "type_info": "Int4" }, { "ordinal": 4, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 5, "name": "cover_image_url", "type_info": "Text" }, { "ordinal": 6, "name": "custom_image_url", "type_info": "Text" }, { "ordinal": 7, "name": "description", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, null, false, false, null, true, true, true ] }, "hash": "1545a42d784a1a5fa8e9ed6128814608b9230b64ce23dcd85de444a7aa01bf9e" } ================================================ FILE: .sqlx/query-15613595695e2e722c45712931ce0eb8d2a3deb1bb665d1f091f354a3ad96b92.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_published_collab\n WHERE workspace_id = $1\n AND publish_name = $2\n AND unpublished_at IS NULL\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ null ] }, "hash": "15613595695e2e722c45712931ce0eb8d2a3deb1bb665d1f091f354a3ad96b92" } ================================================ FILE: .sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_published_view_reaction (comment_id, view_id, created_by, reaction_type)\n VALUES ($1, $2, (SELECT uid FROM af_user WHERE uuid = $3), $4)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Text" ] }, "nullable": [] }, "hash": "16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc" } ================================================ FILE: .sqlx/query-18207c125d5f974894576ee1dcfe406b221e9119f570403ec7a41ae1359b3f6c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n workspace_id,\n inviter AS inviter_uid,\n (SELECT uid FROM public.af_user WHERE LOWER(email) = LOWER(invitee_email)) AS invitee_uid,\n status,\n role_id AS role\n FROM\n public.af_workspace_invitation\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "inviter_uid", "type_info": "Int8" }, { "ordinal": 2, "name": "invitee_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "status", "type_info": "Int2" }, { "ordinal": 4, "name": "role", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, null, false, false ] }, "hash": "18207c125d5f974894576ee1dcfe406b221e9119f570403ec7a41ae1359b3f6c" } ================================================ FILE: .sqlx/query-1ae2809504bb6ea7dabcb5b5acfed09b0dd2e382e9fec3430680192df63876b8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT af.uuid\n FROM af_published_collab apc\n JOIN af_user af ON af.uid = apc.published_by\n WHERE view_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "1ae2809504bb6ea7dabcb5b5acfed09b0dd2e382e9fec3430680192df63876b8" } ================================================ FILE: .sqlx/query-1b1ff4352abb6dad982279ee99c8dccb3621b55a838998c1b9803982ae10f622.json ================================================ { "db_name": "PostgreSQL", "query": " SELECT uid, uuid FROM af_user", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "uuid", "type_info": "Uuid" } ], "parameters": { "Left": [] }, "nullable": [ false, false ] }, "hash": "1b1ff4352abb6dad982279ee99c8dccb3621b55a838998c1b9803982ae10f622" } ================================================ FILE: .sqlx/query-1bd79541a2b351b11ae94fe8a7aad408f9b563fd123099aa701a1e07ce797d2f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n af_user.uuid\n FROM public.af_workspace_member\n JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n AND role_id != $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false ] }, "hash": "1bd79541a2b351b11ae94fe8a7aad408f9b563fd123099aa701a1e07ce797d2f" } ================================================ FILE: .sqlx/query-1c8f022ff5add11376dbbc17efd874dd31fd908c4f17be1bded18dbc689e3b36.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE public.af_workspace\n SET is_initialized = $2\n WHERE workspace_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Bool" ] }, "nullable": [] }, "hash": "1c8f022ff5add11376dbbc17efd874dd31fd908c4f17be1bded18dbc689e3b36" } ================================================ FILE: .sqlx/query-1e36d9b3adf957524af88f997f12e5eeeaabda218c3709540e4a4c2df0180047.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace\n SET settings = $1\n WHERE workspace_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Jsonb", "Uuid" ] }, "nullable": [] }, "hash": "1e36d9b3adf957524af88f997f12e5eeeaabda218c3709540e4a4c2df0180047" } ================================================ FILE: .sqlx/query-21195760ea7ed2dc4eda1dc2bd0eed9afcc63651ba6e67e7db675307e3b87821.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE public.af_workspace\n SET workspace_name = $1\n WHERE workspace_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid" ] }, "nullable": [] }, "hash": "21195760ea7ed2dc4eda1dc2bd0eed9afcc63651ba6e67e7db675307e3b87821" } ================================================ FILE: .sqlx/query-2167ca10f5c560d8d4121d57d425c84482fa1dd52ee6f2cc7934e7d356b0dee6.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT default_published_view_id\n FROM af_workspace\n WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "default_published_view_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ true ] }, "hash": "2167ca10f5c560d8d4121d57d425c84482fa1dd52ee6f2cc7934e7d356b0dee6" } ================================================ FILE: .sqlx/query-21f66ca39be3377f8c5e4b218123e266fe8e03260ecd1891c644820892dda2b2.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT * FROM af_collab_snapshot\n WHERE sid = $1 AND oid = $2 AND workspace_id = $3 AND deleted_at IS NULL;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "sid", "type_info": "Int8" }, { "ordinal": 1, "name": "oid", "type_info": "Text" }, { "ordinal": 2, "name": "blob", "type_info": "Bytea" }, { "ordinal": 3, "name": "len", "type_info": "Int4" }, { "ordinal": 4, "name": "encrypt", "type_info": "Int4" }, { "ordinal": 5, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Int8", "Text", "Uuid" ] }, "nullable": [ false, false, false, false, true, true, false, false ] }, "hash": "21f66ca39be3377f8c5e4b218123e266fe8e03260ecd1891c644820892dda2b2" } ================================================ FILE: .sqlx/query-223e530f8605f6d00789344565666f57705151e3c2318519e877b22f8ffc871b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n apc.view_id,\n apc.publish_name,\n au.email AS publisher_email,\n apc.created_at AS publish_timestamp,\n apc.comments_enabled,\n apc.duplicate_enabled\n FROM af_published_collab apc\n JOIN af_user au ON apc.published_by = au.uid\n WHERE workspace_id = $1\n AND unpublished_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "publish_name", "type_info": "Text" }, { "ordinal": 2, "name": "publisher_email", "type_info": "Text" }, { "ordinal": 3, "name": "publish_timestamp", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "comments_enabled", "type_info": "Bool" }, { "ordinal": 5, "name": "duplicate_enabled", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "223e530f8605f6d00789344565666f57705151e3c2318519e877b22f8ffc871b" } ================================================ FILE: .sqlx/query-229a99b7a3a2f136babd5499c2a1047fe840903acf0d06e57fb78ca9b03e7008.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, snapshot, snapshot_version, created_at\n FROM af_snapshot_meta\n WHERE oid = $1 AND partition_key = $2\n ORDER BY created_at DESC\n LIMIT 1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Text" }, { "ordinal": 1, "name": "snapshot", "type_info": "Bytea" }, { "ordinal": 2, "name": "snapshot_version", "type_info": "Int4" }, { "ordinal": 3, "name": "created_at", "type_info": "Int8" } ], "parameters": { "Left": [ "Text", "Int4" ] }, "nullable": [ false, false, false, false ] }, "hash": "229a99b7a3a2f136babd5499c2a1047fe840903acf0d06e57fb78ca9b03e7008" } ================================================ FILE: .sqlx/query-2394226650959b34ae80b1948b7a111720b3ea5da48934d8d7e395ecc84e6985.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_template_view SET\n updated_at = NOW(),\n name = $2,\n description = $3,\n about = $4,\n view_url = $5,\n creator_id = $6,\n is_new_template = $7,\n is_featured = $8\n WHERE view_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Text", "Uuid", "Bool", "Bool" ] }, "nullable": [] }, "hash": "2394226650959b34ae80b1948b7a111720b3ea5da48934d8d7e395ecc84e6985" } ================================================ FILE: .sqlx/query-24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT name, email FROM af_user WHERE uuid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "name", "type_info": "Text" }, { "ordinal": 1, "name": "email", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false ] }, "hash": "24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716" } ================================================ FILE: .sqlx/query-2593b975fcf2dcf0129a1390fd8e2888d440e07c904d7eb3ca14957be8bc6069.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT * FROM public.af_permissions WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Int4" }, { "ordinal": 1, "name": "name", "type_info": "Varchar" }, { "ordinal": 2, "name": "access_level", "type_info": "Int4" }, { "ordinal": 3, "name": "description", "type_info": "Text" } ], "parameters": { "Left": [ "Int4" ] }, "nullable": [ false, false, false, true ] }, "hash": "2593b975fcf2dcf0129a1390fd8e2888d440e07c904d7eb3ca14957be8bc6069" } ================================================ FILE: .sqlx/query-2902fd3a9faa9481754d38b29abb543640c0b5564dca8f0141c7de2b8aab9551.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid,workspace_id,owner_uid,deleted_at,created_at,updated_at\n FROM af_collab\n WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false, false, false, true, true, false ] }, "hash": "2902fd3a9faa9481754d38b29abb543640c0b5564dca8f0141c7de2b8aab9551" } ================================================ FILE: .sqlx/query-291f0916b7868f3598b50f659689b9c77d34112c2a2fff9fc04775da9f97e46d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_workspace\n WHERE workspace_id = $1\n ) AS user_exists;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "user_exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "291f0916b7868f3598b50f659689b9c77d34112c2a2fff9fc04775da9f97e46d" } ================================================ FILE: .sqlx/query-29279a0a97beb08aea84d588374c7534c28bd9c4da24b1ee20245109f5c33880.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace_member\n SET updated_at = $3\n WHERE uid = $1\n AND workspace_id = $2;\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", "Uuid", "Timestamptz" ] }, "nullable": [] }, "hash": "29279a0a97beb08aea84d588374c7534c28bd9c4da24b1ee20245109f5c33880" } ================================================ FILE: .sqlx/query-2b0754f55889a20c294d2a77ba8d3fa34c8174856abfdede34797851183a177a.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS (\n SELECT 1\n FROM public.af_workspace\n WHERE\n workspace_id = $1\n AND owner_uid = (\n SELECT uid FROM public.af_user WHERE email = $2\n )\n ) AS \"is_owner\";\n ", "describe": { "columns": [ { "ordinal": 0, "name": "is_owner", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ null ] }, "hash": "2b0754f55889a20c294d2a77ba8d3fa34c8174856abfdede34797851183a177a" } ================================================ FILE: .sqlx/query-2c0a776a787bc748857873b682d2fa3c549ffeaf767aa8ee05b09b3857505ded.json ================================================ { "db_name": "PostgreSQL", "query": "\nSELECT\n w.settings['disable_search_indexing']::boolean as disable_search_indexing,\n CASE\n WHEN w.settings['disable_search_indexing']::boolean THEN\n FALSE\n ELSE\n EXISTS (SELECT 1 FROM af_collab_embeddings m WHERE m.oid = $2::uuid)\n END as has_index\nFROM af_workspace w\nWHERE w.workspace_id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "disable_search_indexing", "type_info": "Bool" }, { "ordinal": 1, "name": "has_index", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null, null ] }, "hash": "2c0a776a787bc748857873b682d2fa3c549ffeaf767aa8ee05b09b3857505ded" } ================================================ FILE: .sqlx/query-2c496e29533dd27117fbb688ba2324f04d7cc306181fcf3f82079d5639f632c4.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_chat\n SET deleted_at = now()\n WHERE chat_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "2c496e29533dd27117fbb688ba2324f04d7cc306181fcf3f82079d5639f632c4" } ================================================ FILE: .sqlx/query-2d6d00669ea7d598d69d848d143f33e8c144d35b3d4c5293f98344b2c62fe6c8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT namespace\n FROM af_workspace_namespace\n WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n AND is_original = FALSE\n ORDER BY created_at DESC\n LIMIT 1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "namespace", "type_info": "Text" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false ] }, "hash": "2d6d00669ea7d598d69d848d143f33e8c144d35b3d4c5293f98344b2c62fe6c8" } ================================================ FILE: .sqlx/query-2dda0bc4d9486a49c0af00d8ee4408c970a2ba3533217c130281e7db5a4e3d6b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, metadata\n FROM af_published_collab\n WHERE view_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "metadata", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false ] }, "hash": "2dda0bc4d9486a49c0af00d8ee4408c970a2ba3533217c130281e7db5a4e3d6b" } ================================================ FILE: .sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT true\n FROM af_published_collab\n WHERE view_id = $1\n AND published_by = (SELECT uid FROM af_user WHERE uuid = $2)\n UNION ALL\n SELECT true\n FROM af_published_view_comment\n WHERE view_id = $1\n AND comment_id = $3\n AND created_by = (SELECT uid FROM af_user WHERE uuid = $2)\n ) AS \"exists\";\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5" } ================================================ FILE: .sqlx/query-315840e0657ea0b8d162635b4cc21ce84a09fd7ea14ea07980869a80ee06900c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_page_mention (workspace_id, view_id, view_name, person_id, block_id, mentioned_by, mentioned_at, require_notification)\n VALUES ($1, $2, $3, $4, $5, $6, current_timestamp, $7)\n ON CONFLICT (workspace_id, view_id, person_id) DO UPDATE\n SET mentioned_by = EXCLUDED.mentioned_by,\n mentioned_at = EXCLUDED.mentioned_at,\n block_id = EXCLUDED.block_id,\n require_notification = EXCLUDED.require_notification,\n notified = false\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Text", "Uuid", "Text", "Int8", "Bool" ] }, "nullable": [] }, "hash": "315840e0657ea0b8d162635b4cc21ce84a09fd7ea14ea07980869a80ee06900c" } ================================================ FILE: .sqlx/query-32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (workspace_id, uid) DO NOTHING\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8", "Int4" ] }, "nullable": [] }, "hash": "32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7" } ================================================ FILE: .sqlx/query-340b8cef5a7676541b86505cdf103fcb5b54c40a9d6e599dc1d9dc0a95e1e862.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH creator_number_of_templates AS (\n SELECT\n creator_id,\n COUNT(1)::int AS number_of_templates\n FROM af_template_view\n WHERE name ILIKE $1\n GROUP BY creator_id\n )\n\n SELECT\n creator.creator_id AS \"id!\",\n name AS \"name!\",\n avatar_url AS \"avatar_url!\",\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec\",\n COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n FROM af_template_creator creator\n LEFT OUTER JOIN af_template_creator_account_link account_link\n USING (creator_id)\n LEFT OUTER JOIN creator_number_of_templates\n USING (creator_id)\n WHERE name ILIKE $1\n GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\n ORDER BY created_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "avatar_url!", "type_info": "Text" }, { "ordinal": 3, "name": "account_links: Vec", "type_info": "RecordArray" }, { "ordinal": 4, "name": "number_of_templates!", "type_info": "Int4" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, null, null ] }, "hash": "340b8cef5a7676541b86505cdf103fcb5b54c40a9d6e599dc1d9dc0a95e1e862" } ================================================ FILE: .sqlx/query-354166a6fa147dc6e17bfc14cb68d3a72a2e7c3aa2d115686deb12086786e034.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT role_id FROM af_workspace_member\n WHERE workspace_id = $1 AND uid = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "role_id", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false ] }, "hash": "354166a6fa147dc6e17bfc14cb68d3a72a2e7c3aa2d115686deb12086786e034" } ================================================ FILE: .sqlx/query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_quick_note (workspace_id, uid, data) VALUES ($1, $2, $3)\n RETURNING quick_note_id AS id, data, created_at AS \"created_at!\", updated_at AS \"last_updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "data", "type_info": "Jsonb" }, { "ordinal": 2, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "last_updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Int8", "Jsonb" ] }, "nullable": [ false, false, true, true ] }, "hash": "35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58" } ================================================ FILE: .sqlx/query-36733444fc8fac851fb540105ea6c9dca785455ae44ae518b98d8b57082e11d8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM public.af_workspace_member\n JOIN af_roles ON af_workspace_member.role_id = af_roles.id\n WHERE workspace_id = $1\n AND af_workspace_member.uid = (\n SELECT uid FROM public.af_user WHERE uuid = $2\n )\n AND af_roles.name = 'Owner'\n ) AS \"exists\";\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "36733444fc8fac851fb540105ea6c9dca785455ae44ae518b98d8b57082e11d8" } ================================================ FILE: .sqlx/query-3865d921d76ac0d0eb16065738cddf82cb71945504116b0a04da759209b9c250.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, indexed_at\n FROM af_collab\n WHERE oid = ANY (SELECT UNNEST($1::uuid[]))\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "indexed_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, true ] }, "hash": "3865d921d76ac0d0eb16065738cddf82cb71945504116b0a04da759209b9c250" } ================================================ FILE: .sqlx/query-3b2daf263b4022e69c819edb55d412da8ad3fe4377155d8485fbaf186069f389.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n uuid,\n email,\n name,\n metadata ->> 'icon_url' AS avatar_url\n FROM af_user\n WHERE uid = $1;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email", "type_info": "Text" }, { "ordinal": 2, "name": "name", "type_info": "Text" }, { "ordinal": 3, "name": "avatar_url", "type_info": "Text" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false, false, null ] }, "hash": "3b2daf263b4022e69c819edb55d412da8ad3fe4377155d8485fbaf186069f389" } ================================================ FILE: .sqlx/query-3bb5b82d46c55bbfd51319310a3cd065c4b796462a1ddf3c17617ee65ce9961a.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_chat (chat_id, name, workspace_id, rag_ids)\n VALUES ($1, $2, $3, $4)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Uuid", "Jsonb" ] }, "nullable": [] }, "hash": "3bb5b82d46c55bbfd51319310a3cd065c4b796462a1ddf3c17617ee65ce9961a" } ================================================ FILE: .sqlx/query-3c2c94b9ac0a329b92847d7176a7435f894c5ef3b3b11e3e2ae03a8ec454a6d8.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH af_user_row AS (\n SELECT * FROM af_user WHERE uuid = $1\n )\n SELECT\n af_user_row.uid,\n af_user_row.uuid,\n af_user_row.email,\n af_user_row.password,\n af_user_row.name,\n af_user_row.metadata,\n af_user_row.encryption_sign,\n af_user_row.deleted_at,\n af_user_row.updated_at,\n af_user_row.created_at,\n (\n SELECT af_workspace_member.workspace_id\n FROM af_workspace_member\n JOIN af_workspace\n ON af_workspace_member.workspace_id = af_workspace.workspace_id\n WHERE af_workspace_member.uid = af_user_row.uid\n AND COALESCE(af_workspace.is_initialized, true) = true\n ORDER BY af_workspace_member.updated_at DESC\n LIMIT 1\n ) AS latest_workspace_id\n FROM af_user_row\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "password", "type_info": "Text" }, { "ordinal": 4, "name": "name", "type_info": "Text" }, { "ordinal": 5, "name": "metadata", "type_info": "Jsonb" }, { "ordinal": 6, "name": "encryption_sign", "type_info": "Text" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "latest_workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true, true, true, true, null ] }, "hash": "3c2c94b9ac0a329b92847d7176a7435f894c5ef3b3b11e3e2ae03a8ec454a6d8" } ================================================ FILE: .sqlx/query-3ca587826f0598e7786c765dcb2fcd6ae08d8aa404f02920307547c769a3f91b.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH\n updated_creator AS (\n UPDATE af_template_creator\n SET name = $2, avatar_url = $3, updated_at = NOW()\n WHERE creator_id = $1\n RETURNING creator_id, name, avatar_url\n ),\n account_links AS (\n INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n SELECT updated_creator.creator_id as creator_id, link_type, url FROM\n UNNEST($4::text[], $5::text[]) AS t(link_type, url)\n CROSS JOIN updated_creator\n RETURNING\n creator_id,\n link_type,\n url\n ),\n creator_number_of_templates AS (\n SELECT\n creator_id,\n COUNT(1)::int AS number_of_templates\n FROM af_template_view\n WHERE creator_id = $1\n GROUP BY creator_id\n )\n SELECT\n updated_creator.creator_id AS id,\n name,\n avatar_url,\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec\",\n COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n FROM updated_creator\n LEFT OUTER JOIN account_links\n USING (creator_id)\n LEFT OUTER JOIN creator_number_of_templates\n USING (creator_id)\n GROUP BY (id, name, avatar_url, number_of_templates)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 3, "name": "account_links: Vec", "type_info": "RecordArray" }, { "ordinal": 4, "name": "number_of_templates!", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "TextArray", "TextArray" ] }, "nullable": [ false, false, false, null, null ] }, "hash": "3ca587826f0598e7786c765dcb2fcd6ae08d8aa404f02920307547c769a3f91b" } ================================================ FILE: .sqlx/query-3cfb0a6d9a798f29422bc4bf4a52d3c86c3aae98c173b83c60eb57504a3d2c7c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_snapshot_meta (oid, workspace_id, snapshot, snapshot_version, partition_key, created_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT DO NOTHING\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid", "Bytea", "Int4", "Int4", "Int8" ] }, "nullable": [] }, "hash": "3cfb0a6d9a798f29422bc4bf4a52d3c86c3aae98c173b83c60eb57504a3d2c7c" } ================================================ FILE: .sqlx/query-3d3309a4ae7a88b3f7c9608dd78a1c1dc9b237a37e29722bcd2910bd23f9d873.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_related_template_view\n WHERE view_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "3d3309a4ae7a88b3f7c9608dd78a1c1dc9b237a37e29722bcd2910bd23f9d873" } ================================================ FILE: .sqlx/query-3fdd28c263edf5c91ab8b770e6106d4890ec4bae2ff3c20f80c40cb4042d9e03.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT SUM(len) FROM af_collab WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "sum", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "3fdd28c263edf5c91ab8b770e6106d4890ec4bae2ff3c20f80c40cb4042d9e03" } ================================================ FILE: .sqlx/query-40db0a61665bdb9f7e9d1ce2a6c0eb05703e36e83c87802a72630388588de8cd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, COUNT(*) AS member_count\n FROM af_workspace_member\n WHERE workspace_id = ANY($1) AND role_id != $2\n GROUP BY workspace_id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "member_count", "type_info": "Int8" } ], "parameters": { "Left": [ "UuidArray", "Int4" ] }, "nullable": [ false, null ] }, "hash": "40db0a61665bdb9f7e9d1ce2a6c0eb05703e36e83c87802a72630388588de8cd" } ================================================ FILE: .sqlx/query-4123fa8796e8b56225155f79c2ee4c4dacda5ef51e858ce7dcb9877c7d55bd53.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH invited_workspace_member AS (\n SELECT\n invite_code,\n COUNT(*) AS member_count,\n COUNT(CASE WHEN uid = $2 THEN uid END) > 0 AS is_member\n FROM af_workspace_invite_code\n JOIN af_workspace_member USING (workspace_id)\n WHERE invite_code = $1\n AND (expires_at IS NULL OR expires_at > NOW())\n GROUP BY invite_code\n )\n SELECT\n workspace_id,\n owner_profile.name AS \"owner_name!\",\n owner_profile.metadata ->> 'icon_url' AS owner_avatar,\n af_workspace.workspace_name AS \"workspace_name!\",\n af_workspace.icon AS workspace_icon_url,\n invited_workspace_member.member_count AS \"member_count!\",\n invited_workspace_member.is_member AS \"is_member!\"\n FROM af_workspace_invite_code\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN invited_workspace_member USING (invite_code)\n WHERE invite_code = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "owner_name!", "type_info": "Text" }, { "ordinal": 2, "name": "owner_avatar", "type_info": "Text" }, { "ordinal": 3, "name": "workspace_name!", "type_info": "Text" }, { "ordinal": 4, "name": "workspace_icon_url", "type_info": "Text" }, { "ordinal": 5, "name": "member_count!", "type_info": "Int8" }, { "ordinal": 6, "name": "is_member!", "type_info": "Bool" } ], "parameters": { "Left": [ "Text", "Int8" ] }, "nullable": [ false, false, null, true, false, null, null ] }, "hash": "4123fa8796e8b56225155f79c2ee4c4dacda5ef51e858ce7dcb9877c7d55bd53" } ================================================ FILE: .sqlx/query-425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_template_category\n WHERE category_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6" } ================================================ FILE: .sqlx/query-441316f35ca8c24bf78167f9fec48e28c05969bbbbe3d0e3d9e1569a375de476.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT * FROM af_blob_metadata\n WHERE workspace_id = $1 AND file_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "file_id", "type_info": "Varchar" }, { "ordinal": 2, "name": "file_type", "type_info": "Varchar" }, { "ordinal": 3, "name": "file_size", "type_info": "Int8" }, { "ordinal": 4, "name": "modified_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "status", "type_info": "Int2" }, { "ordinal": 6, "name": "source", "type_info": "Int2" }, { "ordinal": 7, "name": "source_metadata", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, false, false, false, false, true ] }, "hash": "441316f35ca8c24bf78167f9fec48e28c05969bbbbe3d0e3d9e1569a375de476" } ================================================ FILE: .sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_access_request\n WHERE request_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b" } ================================================ FILE: .sqlx/query-44e4be501db0375fbd8ad8ed923bef887e361fe466ab46bdd6663f6cf97413a8.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH new_workspace AS (\n INSERT INTO public.af_workspace (owner_uid, workspace_name, icon, is_initialized)\n VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2, $3, $4)\n RETURNING *\n )\n SELECT\n workspace_id,\n database_storage_id,\n owner_uid,\n owner_profile.name AS owner_name,\n owner_profile.email AS owner_email,\n new_workspace.created_at,\n workspace_type,\n new_workspace.deleted_at,\n workspace_name,\n icon\n FROM new_workspace\n JOIN public.af_user AS owner_profile ON new_workspace.owner_uid = owner_profile.uid;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "database_storage_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "owner_name", "type_info": "Text" }, { "ordinal": 4, "name": "owner_email", "type_info": "Text" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_type", "type_info": "Int4" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 9, "name": "icon", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Bool" ] }, "nullable": [ false, false, false, false, false, true, false, true, true, false ] }, "hash": "44e4be501db0375fbd8ad8ed923bef887e361fe466ab46bdd6663f6cf97413a8" } ================================================ FILE: .sqlx/query-4f5951e61713d04963524b84648c9ff8c7be05f0089f6fd26fc6e0e0afeae579.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT reply_message_id\n FROM af_chat_messages\n WHERE message_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "reply_message_id", "type_info": "Int8" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ true ] }, "hash": "4f5951e61713d04963524b84648c9ff8c7be05f0089f6fd26fc6e0e0afeae579" } ================================================ FILE: .sqlx/query-4fc0611c846f86be652d42eb8ae21a5da0353fe810856aaabe91d7963329d098.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n ac.oid as object_id,\n ace.partition_key,\n ac.indexed_at,\n ace.updated_at\n FROM af_collab_embeddings ac\n JOIN af_collab ace ON ac.oid = ace.oid\n WHERE ac.oid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "object_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "partition_key", "type_info": "Int4" }, { "ordinal": 2, "name": "indexed_at", "type_info": "Timestamp" }, { "ordinal": 3, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "4fc0611c846f86be652d42eb8ae21a5da0353fe810856aaabe91d7963329d098" } ================================================ FILE: .sqlx/query-51a3a723b1825da7b9abd9cb36db0cf8220abf063098a73e4a6fc3f87352b395.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT metadata\n FROM af_published_collab\n WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n AND unpublished_at IS NULL\n AND publish_name = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "metadata", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Text", "Text" ] }, "nullable": [ false ] }, "hash": "51a3a723b1825da7b9abd9cb36db0cf8220abf063098a73e4a6fc3f87352b395" } ================================================ FILE: .sqlx/query-523087b0101a35abfc70a561272acec7a357491a86901f7927b8242173b5c8c8.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH\n new_creator AS (\n INSERT INTO af_template_creator (name, avatar_url)\n VALUES ($1, $2)\n RETURNING creator_id, name, avatar_url\n ),\n account_links AS (\n INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n SELECT new_creator.creator_id as creator_id, link_type, url FROM\n UNNEST($3::text[], $4::text[]) AS t(link_type, url)\n CROSS JOIN new_creator\n RETURNING\n creator_id,\n link_type,\n url\n )\n SELECT\n new_creator.creator_id AS id,\n name,\n avatar_url,\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec\",\n 0 AS \"number_of_templates!\"\n FROM new_creator\n LEFT OUTER JOIN account_links\n USING (creator_id)\n GROUP BY (id, name, avatar_url)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 3, "name": "account_links: Vec", "type_info": "RecordArray" }, { "ordinal": 4, "name": "number_of_templates!", "type_info": "Int4" } ], "parameters": { "Left": [ "Text", "Text", "TextArray", "TextArray" ] }, "nullable": [ false, false, false, null, null ] }, "hash": "523087b0101a35abfc70a561272acec7a357491a86901f7927b8242173b5c8c8" } ================================================ FILE: .sqlx/query-52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO public.af_workspace_invitation (\n id,\n workspace_id,\n inviter,\n invitee_email,\n role_id\n )\n VALUES (\n $1,\n $2,\n (SELECT uid FROM public.af_user WHERE uuid = $3),\n $4,\n $5\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Text", "Int4" ] }, "nullable": [] }, "hash": "52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04" } ================================================ FILE: .sqlx/query-53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec\"\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "reaction_type", "type_info": "Text" }, { "ordinal": 2, "name": "react_users!: Vec", "type_info": "RecordArray" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, null ] }, "hash": "53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb" } ================================================ FILE: .sqlx/query-594af4041e0778476a699536316007f0a264f7d3db9de6326ef8082a2a898995.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT id, invitee_email\n FROM public.af_workspace_invitation\n WHERE workspace_id = $1\n AND status = 0\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "invitee_email", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false ] }, "hash": "594af4041e0778476a699536316007f0a264f7d3db9de6326ef8082a2a898995" } ================================================ FILE: .sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_access_request (\n workspace_id,\n view_id,\n uid,\n status\n )\n VALUES ($1, $2, $3, $4)\n RETURNING request_id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "request_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Int8", "Int4" ] }, "nullable": [ false ] }, "hash": "598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c" } ================================================ FILE: .sqlx/query-59b2a7854bb8f0d7ee34b9dfa4e3db5cac8e25fdebe186ba2cbd65012eb91f5f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, view_id\n FROM af_published_collab\n WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n AND publish_name = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "view_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Text", "Text" ] }, "nullable": [ false, false ] }, "hash": "59b2a7854bb8f0d7ee34b9dfa4e3db5cac8e25fdebe186ba2cbd65012eb91f5f" } ================================================ FILE: .sqlx/query-5c2d58bfdedbb1be71337a97d5ed5a2921f83dd549507b2834a4d2582d2c361b.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_collab_embeddings e\n USING af_collab c\n WHERE e.oid = c.oid\n AND c.workspace_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "5c2d58bfdedbb1be71337a97d5ed5a2921f83dd549507b2834a4d2582d2c361b" } ================================================ FILE: .sqlx/query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM af_quick_note WHERE quick_note_id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da" } ================================================ FILE: .sqlx/query-5d408d36790ade4da1ceeb68b4a183aa7d9abc27b0ec42c2a3c5af26ad80f128.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT uid FROM af_user WHERE uuid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "5d408d36790ade4da1ceeb68b4a183aa7d9abc27b0ec42c2a3c5af26ad80f128" } ================================================ FILE: .sqlx/query-5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_template_creator_account_link\n WHERE creator_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557" } ================================================ FILE: .sqlx/query-5e0d58f612425e1cf36dfc7f56691cfb8f6def1a3d29645922cb437d11ce62ef.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*)\n FROM public.af_chat_messages\n WHERE chat_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "5e0d58f612425e1cf36dfc7f56691cfb8f6def1a3d29645922cb437d11ce62ef" } ================================================ FILE: .sqlx/query-620167841bb2acdd1c9c6aadf8245e3a483d87dc006d4e361e994ce2c5d768cd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_workspace_namespace\n WHERE namespace = $1\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ null ] }, "hash": "620167841bb2acdd1c9c6aadf8245e3a483d87dc006d4e361e994ce2c5d768cd" } ================================================ FILE: .sqlx/query-62ed61bcf92fc0c3756f57d0fe05cdd12e70072f5646fe48790ad189a6e96b12.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH template_with_creator_account_link AS (\n SELECT\n template.view_id,\n template.creator_id,\n COALESCE(\n ARRAY_AGG((link_type, url)::account_link_type) FILTER (WHERE link_type IS NOT NULL),\n '{}'\n ) AS account_links\n FROM af_template_view template\n JOIN af_published_collab\n USING (view_id)\n JOIN af_template_creator creator\n USING (creator_id)\n LEFT OUTER JOIN af_template_creator_account_link account_link\n USING (creator_id)\n WHERE view_id = $1\n GROUP BY (view_id, template.creator_id)\n ),\n related_template_with_category AS (\n SELECT\n template.related_view_id,\n ARRAY_AGG(\n (\n template_category.category_id,\n template_category.name,\n template_category.icon,\n template_category.bg_color\n )::template_category_minimal_type\n ) AS categories\n FROM af_related_template_view template\n JOIN af_template_view_template_category template_template_category\n ON template.related_view_id = template_template_category.view_id\n JOIN af_template_category template_category\n USING (category_id)\n WHERE template.view_id = $1\n GROUP BY template.related_view_id\n ),\n template_with_related_template AS (\n SELECT\n template.view_id,\n ARRAY_AGG(\n (\n template.related_view_id,\n related_template.created_at,\n related_template.updated_at,\n related_template.name,\n related_template.description,\n related_template.view_url,\n (\n creator.creator_id,\n creator.name,\n creator.avatar_url\n )::template_creator_minimal_type,\n related_template_with_category.categories,\n related_template.is_new_template,\n related_template.is_featured\n )::template_minimal_type\n ) AS related_templates\n FROM af_related_template_view template\n JOIN af_template_view related_template\n ON template.related_view_id = related_template.view_id\n JOIN af_template_creator creator\n ON related_template.creator_id = creator.creator_id\n JOIN related_template_with_category\n ON template.related_view_id = related_template_with_category.related_view_id\n WHERE template.view_id = $1\n GROUP BY template.view_id\n ),\n template_with_category AS (\n SELECT\n view_id,\n COALESCE(\n ARRAY_AGG((\n vtc.category_id,\n name,\n icon,\n bg_color,\n description,\n category_type,\n priority\n )) FILTER (WHERE vtc.category_id IS NOT NULL),\n '{}'\n ) AS categories\n FROM af_template_view_template_category vtc\n JOIN af_template_category tc\n ON vtc.category_id = tc.category_id\n WHERE view_id = $1\n GROUP BY view_id\n ),\n creator_number_of_templates AS (\n SELECT\n creator_id,\n COUNT(*) AS number_of_templates\n FROM af_template_view\n GROUP BY creator_id\n )\n\n SELECT\n template.view_id,\n template.created_at,\n template.updated_at,\n template.name,\n template.description,\n template.about,\n template.view_url,\n (\n creator.creator_id,\n creator.name,\n creator.avatar_url,\n template_with_creator_account_link.account_links,\n creator_number_of_templates.number_of_templates\n )::template_creator_type AS \"creator!: AFTemplateCreatorRow\",\n template_with_category.categories AS \"categories!: Vec\",\n COALESCE(template_with_related_template.related_templates, '{}') AS \"related_templates!: Vec\",\n template.is_new_template,\n template.is_featured\n FROM af_template_view template\n JOIN af_template_creator creator\n USING (creator_id)\n JOIN template_with_creator_account_link\n ON template.view_id = template_with_creator_account_link.view_id\n LEFT OUTER JOIN template_with_related_template\n ON template.view_id = template_with_related_template.view_id\n JOIN template_with_category\n ON template.view_id = template_with_category.view_id\n LEFT OUTER JOIN creator_number_of_templates\n ON template.creator_id = creator_number_of_templates.creator_id\n WHERE template.view_id = $1\n\n ", "describe": { "columns": [ { "ordinal": 0, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 2, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "name", "type_info": "Text" }, { "ordinal": 4, "name": "description", "type_info": "Text" }, { "ordinal": 5, "name": "about", "type_info": "Text" }, { "ordinal": 6, "name": "view_url", "type_info": "Text" }, { "ordinal": 7, "name": "creator!: AFTemplateCreatorRow", "type_info": { "Custom": { "name": "template_creator_type", "kind": { "Composite": [ [ "creator_id", "Uuid" ], [ "name", "Text" ], [ "avatar_url", "Text" ], [ "account_links", { "Custom": { "name": "account_link_type[]", "kind": { "Array": { "Custom": { "name": "account_link_type", "kind": { "Composite": [ [ "link_type", "Text" ], [ "url", "Text" ] ] } } } } } } ], [ "number_of_templates", "Int4" ] ] } } } }, { "ordinal": 8, "name": "categories!: Vec", "type_info": "RecordArray" }, { "ordinal": 9, "name": "related_templates!: Vec", "type_info": { "Custom": { "name": "template_minimal_type[]", "kind": { "Array": { "Custom": { "name": "template_minimal_type", "kind": { "Composite": [ [ "view_id", "Uuid" ], [ "created_at", "Timestamptz" ], [ "updated_at", "Timestamptz" ], [ "name", "Text" ], [ "description", "Text" ], [ "view_url", "Text" ], [ "creator", { "Custom": { "name": "template_creator_minimal_type", "kind": { "Composite": [ [ "creator_id", "Uuid" ], [ "name", "Text" ], [ "avatar_url", "Text" ] ] } } } ], [ "categories", { "Custom": { "name": "template_category_minimal_type[]", "kind": { "Array": { "Custom": { "name": "template_category_minimal_type", "kind": { "Composite": [ [ "category_id", "Uuid" ], [ "name", "Text" ], [ "icon", "Text" ], [ "bg_color", "Text" ] ] } } } } } } ], [ "is_new_template", "Bool" ], [ "is_featured", "Bool" ] ] } } } } } } }, { "ordinal": 10, "name": "is_new_template", "type_info": "Bool" }, { "ordinal": 11, "name": "is_featured", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false, null, null, null, false, false ] }, "hash": "62ed61bcf92fc0c3756f57d0fe05cdd12e70072f5646fe48790ad189a6e96b12" } ================================================ FILE: .sqlx/query-6380f5a6ded2dab8f18de42541c9d77c2f3af512e3f66e1b731ca7c00c9ea8f8.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO public.af_workspace_member (workspace_id, uid, role_id)\n SELECT $1, af_user.uid, $3\n FROM public.af_user\n WHERE\n af_user.email = $2\n ON CONFLICT (workspace_id, uid)\n DO NOTHING;\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Int4" ] }, "nullable": [] }, "hash": "6380f5a6ded2dab8f18de42541c9d77c2f3af512e3f66e1b731ca7c00c9ea8f8" } ================================================ FILE: .sqlx/query-63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec\",\n avr.comment_id\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "reaction_type", "type_info": "Text" }, { "ordinal": 1, "name": "react_users!: Vec", "type_info": "RecordArray" }, { "ordinal": 2, "name": "comment_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, null, false ] }, "hash": "63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f" } ================================================ FILE: .sqlx/query-66218110851919b05b95b008a17547547d23f6baeeff8a5521b2b246126adc34.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT name, meta_data, rag_ids\n FROM af_chat\n WHERE chat_id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "name", "type_info": "Text" }, { "ordinal": 1, "name": "meta_data", "type_info": "Jsonb" }, { "ordinal": 2, "name": "rag_ids", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "66218110851919b05b95b008a17547547d23f6baeeff8a5521b2b246126adc34" } ================================================ FILE: .sqlx/query-6716ec4787f7155af97a4890730f4b3fe564ead8d99f8355ac249f9b39316238.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH workspace_member_count AS (\n SELECT\n workspace_id,\n COUNT(*) AS member_count\n FROM af_workspace_member\n WHERE workspace_id = $1 AND role_id != $3\n GROUP BY workspace_id\n )\n\n SELECT\n af_workspace.workspace_id,\n database_storage_id,\n owner_uid,\n owner_profile.name as owner_name,\n owner_profile.email as owner_email,\n af_workspace.created_at,\n workspace_type,\n af_workspace.deleted_at,\n workspace_name,\n icon,\n workspace_member_count.member_count AS \"member_count!\",\n role_id AS \"role!\"\n FROM public.af_workspace\n JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN af_workspace_member ON (af_workspace.workspace_id = af_workspace_member.workspace_id\n AND af_workspace_member.uid = $2)\n JOIN workspace_member_count ON af_workspace.workspace_id = workspace_member_count.workspace_id\n WHERE af_workspace.workspace_id = $1\n AND COALESCE(af_workspace.is_initialized, true) = true;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "database_storage_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "owner_name", "type_info": "Text" }, { "ordinal": 4, "name": "owner_email", "type_info": "Text" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_type", "type_info": "Int4" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 9, "name": "icon", "type_info": "Text" }, { "ordinal": 10, "name": "member_count!", "type_info": "Int8" }, { "ordinal": 11, "name": "role!", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Int8", "Int4" ] }, "nullable": [ false, false, false, false, false, true, false, true, true, false, null, false ] }, "hash": "6716ec4787f7155af97a4890730f4b3fe564ead8d99f8355ac249f9b39316238" } ================================================ FILE: .sqlx/query-67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH request_id_workspace_member_count AS (\n SELECT\n request_id,\n COUNT(*) AS member_count\n FROM af_access_request\n JOIN af_workspace_member USING (workspace_id)\n WHERE request_id = $1\n GROUP BY request_id\n )\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n owner_profile.email,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon,\n request_id_workspace_member_count.member_count\n ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n (\n af_user.uid,\n af_user.uuid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN request_id_workspace_member_count USING (request_id)\n WHERE request_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "request_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "workspace!: AFWorkspaceWithMemberCountRow", "type_info": "Record" }, { "ordinal": 3, "name": "requester!: AFAccessRequesterColumn", "type_info": "Record" }, { "ordinal": 4, "name": "status: AFAccessRequestStatusColumn", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, null, null, false, false ] }, "hash": "67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129" } ================================================ FILE: .sqlx/query-6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_name\n FROM public.af_workspace\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_name", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ true ] }, "hash": "6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553" } ================================================ FILE: .sqlx/query-6935572cb23700243fbbd3dc382cdbf56edaadc4aab7855c237bce68e29414c0.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, blob\n FROM af_collab\n WHERE oid = ANY($1) AND deleted_at IS NULL;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob", "type_info": "Bytea" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, false ] }, "hash": "6935572cb23700243fbbd3dc382cdbf56edaadc4aab7855c237bce68e29414c0" } ================================================ FILE: .sqlx/query-6aca3fde126cb1761c0a5ce1fbfa793bdbac4aed137cdf60eb3f277f36d7bf7a.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n priority\n FROM af_template_category\n WHERE category_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "description", "type_info": "Text" }, { "ordinal": 3, "name": "icon", "type_info": "Text" }, { "ordinal": 4, "name": "bg_color", "type_info": "Text" }, { "ordinal": 5, "name": "category_type: AFTemplateCategoryTypeColumn", "type_info": "Int4" }, { "ordinal": 6, "name": "priority", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "6aca3fde126cb1761c0a5ce1fbfa793bdbac4aed137cdf60eb3f277f36d7bf7a" } ================================================ FILE: .sqlx/query-6ca2a2fa10d5334183d98176998d41f36948fe5624e290a32d0b50bc9fb256bf.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n updated_at as updated_at,\n oid as row_id\n FROM af_collab\n WHERE workspace_id = $1\n AND oid = ANY($2)\n AND updated_at > $3\n ", "describe": { "columns": [ { "ordinal": 0, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 1, "name": "row_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "UuidArray", "Timestamptz" ] }, "nullable": [ false, false ] }, "hash": "6ca2a2fa10d5334183d98176998d41f36948fe5624e290a32d0b50bc9fb256bf" } ================================================ FILE: .sqlx/query-6cc4a7da11a37413c9951983ee3f30de933cc6357a66c8e10366fde27acaefea.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_blob_metadata\n (workspace_id, file_id, file_type, file_size)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, file_id) DO UPDATE SET\n file_type = $3,\n file_size = $4\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Varchar", "Varchar", "Int8" ] }, "nullable": [] }, "hash": "6cc4a7da11a37413c9951983ee3f30de933cc6357a66c8e10366fde27acaefea" } ================================================ FILE: .sqlx/query-6f5d6d79587d7f7a52c920acccfe338a8c001ea30b722d3a6a1a60259d47913c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT content,meta_data\n FROM af_chat_messages\n WHERE message_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "content", "type_info": "Text" }, { "ordinal": 1, "name": "meta_data", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false ] }, "hash": "6f5d6d79587d7f7a52c920acccfe338a8c001ea30b722d3a6a1a60259d47913c" } ================================================ FILE: .sqlx/query-6fbcd1c32c638530461c74f8c8195a5b1e1e6f7a389a6a60d889c88c5f47302a.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS (\n SELECT 1 FROM af_collab\n WHERE oid = $1 AND deleted_at IS NULL\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "6fbcd1c32c638530461c74f8c8195a5b1e1e6f7a389a6a60d889c88c5f47302a" } ================================================ FILE: .sqlx/query-71c15686124c05a4fdef066738eadd0ab17d6af1bfeffc480c8fe52a4e6edab8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT file_id FROM af_blob_metadata\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "file_id", "type_info": "Varchar" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "71c15686124c05a4fdef066738eadd0ab17d6af1bfeffc480c8fe52a4e6edab8" } ================================================ FILE: .sqlx/query-74de473589a405c3ab567e72a881869321095e2de497b2c1866c547f939c359c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT * FROM af_blob_metadata\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "file_id", "type_info": "Varchar" }, { "ordinal": 2, "name": "file_type", "type_info": "Varchar" }, { "ordinal": 3, "name": "file_size", "type_info": "Int8" }, { "ordinal": 4, "name": "modified_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "status", "type_info": "Int2" }, { "ordinal": 6, "name": "source", "type_info": "Int2" }, { "ordinal": 7, "name": "source_metadata", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false, true ] }, "hash": "74de473589a405c3ab567e72a881869321095e2de497b2c1866c547f939c359c" } ================================================ FILE: .sqlx/query-75dc8578510ae696bf4bcdd780f7cefc666b4436cf53edf30a98dd2ff7926799.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n au.uuid,\n COALESCE(awmp.name, au.name) AS \"name!\",\n au.email,\n awm.role_id AS \"role!\",\n COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n awmp.cover_image_url,\n awmp.custom_image_url,\n awmp.description\n FROM af_workspace_member awm\n JOIN af_user au ON awm.uid = au.uid\n LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n WHERE awm.workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "role!", "type_info": "Int4" }, { "ordinal": 4, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 5, "name": "cover_image_url", "type_info": "Text" }, { "ordinal": 6, "name": "custom_image_url", "type_info": "Text" }, { "ordinal": 7, "name": "description", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, null, false, false, null, true, true, true ] }, "hash": "75dc8578510ae696bf4bcdd780f7cefc666b4436cf53edf30a98dd2ff7926799" } ================================================ FILE: .sqlx/query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json ================================================ { "db_name": "PostgreSQL", "query": "UPDATE af_quick_note SET data = $1, updated_at = NOW() WHERE quick_note_id = $2", "describe": { "columns": [], "parameters": { "Left": [ "Jsonb", "Uuid" ] }, "nullable": [] }, "hash": "770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b" } ================================================ FILE: .sqlx/query-786a59b28265397658aecf0318beeedece2a7f5bea80b9189f3989721035c593.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH user_workspace_id AS (\n SELECT workspace_id\n FROM af_workspace_member\n JOIN af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_user.uuid = $1\n ),\n workspace_member_count AS (\n SELECT\n workspace_id,\n COUNT(*) AS member_count\n FROM af_workspace_member\n JOIN user_workspace_id USING (workspace_id)\n WHERE role_id != $2\n GROUP BY workspace_id\n )\n\n SELECT\n w.workspace_id,\n w.database_storage_id,\n w.owner_uid,\n u.name AS owner_name,\n u.email AS owner_email,\n w.created_at,\n w.workspace_type,\n w.deleted_at,\n w.workspace_name,\n w.icon,\n wmc.member_count AS \"member_count!\",\n wm.role_id AS \"role!\"\n FROM af_workspace w\n JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n JOIN public.af_user u ON w.owner_uid = u.uid\n JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\n WHERE wm.uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n )\n AND COALESCE(w.is_initialized, true) = true;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "database_storage_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "owner_name", "type_info": "Text" }, { "ordinal": 4, "name": "owner_email", "type_info": "Text" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_type", "type_info": "Int4" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 9, "name": "icon", "type_info": "Text" }, { "ordinal": 10, "name": "member_count!", "type_info": "Int8" }, { "ordinal": 11, "name": "role!", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false, false, false, false, false, true, false, true, true, false, null, false ] }, "hash": "786a59b28265397658aecf0318beeedece2a7f5bea80b9189f3989721035c593" } ================================================ FILE: .sqlx/query-78a191e21a7e7a07eee88ed02c7fbf7035f908b8e4057f7ace1b3b5d433424fe.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_collab\n SET deleted_at = $2\n WHERE oid = $1;\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Timestamptz" ] }, "nullable": [] }, "hash": "78a191e21a7e7a07eee88ed02c7fbf7035f908b8e4057f7ace1b3b5d433424fe" } ================================================ FILE: .sqlx/query-794c4ced16801b3e98a62eb44c18c14137dd09b11be73442a7f46b2f938b8445.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT message_id, content, created_at, author, meta_data, reply_message_id\n FROM af_chat_messages\n WHERE chat_id = $1\n AND reply_message_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "message_id", "type_info": "Int8" }, { "ordinal": 1, "name": "content", "type_info": "Text" }, { "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "author", "type_info": "Jsonb" }, { "ordinal": 4, "name": "meta_data", "type_info": "Jsonb" }, { "ordinal": 5, "name": "reply_message_id", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false, false, false, false, false, true ] }, "hash": "794c4ced16801b3e98a62eb44c18c14137dd09b11be73442a7f46b2f938b8445" } ================================================ FILE: .sqlx/query-7a4c7da16e99ff3875bdd7e0d189e26c3c1ab49672bace41992aecc446061850.json ================================================ { "db_name": "PostgreSQL", "query": "SElECT settings FROM af_workspace WHERE workspace_id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "settings", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ true ] }, "hash": "7a4c7da16e99ff3875bdd7e0d189e26c3c1ab49672bace41992aecc446061850" } ================================================ FILE: .sqlx/query-7a86f93afe6e77d4481920b08ed38926446f6473107d68dfcd82ffecddcee890.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n awn.namespace,\n apc.publish_name,\n apc.view_id,\n au.email AS publisher_email,\n apc.created_at AS publish_timestamp,\n apc.unpublished_at AS unpublished_timestamp,\n apc.comments_enabled,\n apc.duplicate_enabled\n FROM af_published_collab apc\n JOIN af_user au ON apc.published_by = au.uid\n JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\n JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\n WHERE apc.view_id = ANY($1);\n ", "describe": { "columns": [ { "ordinal": 0, "name": "namespace", "type_info": "Text" }, { "ordinal": 1, "name": "publish_name", "type_info": "Text" }, { "ordinal": 2, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "publisher_email", "type_info": "Text" }, { "ordinal": 4, "name": "publish_timestamp", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "unpublished_timestamp", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "comments_enabled", "type_info": "Bool" }, { "ordinal": 7, "name": "duplicate_enabled", "type_info": "Bool" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, false, false, false, false, true, false, false ] }, "hash": "7a86f93afe6e77d4481920b08ed38926446f6473107d68dfcd82ffecddcee890" } ================================================ FILE: .sqlx/query-7aa6e41c80f0b2906d46e73ae05e8e70e133b7edd450b102715b8a487d6055ac.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT metadata, blob\n FROM af_published_collab\n WHERE view_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "metadata", "type_info": "Jsonb" }, { "ordinal": 1, "name": "blob", "type_info": "Bytea" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false ] }, "hash": "7aa6e41c80f0b2906d46e73ae05e8e70e133b7edd450b102715b8a487d6055ac" } ================================================ FILE: .sqlx/query-7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT name FROM af_user WHERE uuid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "name", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22" } ================================================ FILE: .sqlx/query-811b6b01de4fdb06ad58185a5c49dfaa31aef8ea30ab3421d4afc13822fc0a9c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT c.oid, c.partition_key, c.updated_at, c.blob\n FROM af_collab c\n WHERE c.workspace_id = $1\n AND c.deleted_at IS NULL\n AND c.created_at > $2\n ORDER BY updated_at\n LIMIT $3\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "partition_key", "type_info": "Int4" }, { "ordinal": 2, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "blob", "type_info": "Bytea" } ], "parameters": { "Left": [ "Uuid", "Timestamptz", "Int8" ] }, "nullable": [ false, false, false, false ] }, "hash": "811b6b01de4fdb06ad58185a5c49dfaa31aef8ea30ab3421d4afc13822fc0a9c" } ================================================ FILE: .sqlx/query-816a026ca4c25329b2fb24d59efde9ab71798ff8b31ce7320e02344d4e8b3e42.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_published_view_reaction\n WHERE comment_id = $1 AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) AND reaction_type = $3\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Text" ] }, "nullable": [] }, "hash": "816a026ca4c25329b2fb24d59efde9ab71798ff8b31ce7320e02344d4e8b3e42" } ================================================ FILE: .sqlx/query-834638eb3c38eb2c220aa23ac928874d87606b47ef3bb80540614ce2f8453936.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_snapshot_state (oid, workspace_id, doc_state, doc_state_version, deps_snapshot_id, partition_key, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid", "Bytea", "Int4", "Uuid", "Int4", "Int8" ] }, "nullable": [] }, "hash": "834638eb3c38eb2c220aa23ac928874d87606b47ef3bb80540614ce2f8453936" } ================================================ FILE: .sqlx/query-842243ea6ca59135ae539060ff37b80791e76aa268a44642ede515f315e80c01.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_chat_messages\n WHERE message_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8" ] }, "nullable": [] }, "hash": "842243ea6ca59135ae539060ff37b80791e76aa268a44642ede515f315e80c01" } ================================================ FILE: .sqlx/query-84c224af99f654e2e0ba11a411376794855483eedb0c30b1873ed660ca8d10cd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n uuid,\n name,\n metadata ->> 'icon_url' AS avatar_url\n FROM af_user\n WHERE uid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "avatar_url", "type_info": "Text" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false, null ] }, "hash": "84c224af99f654e2e0ba11a411376794855483eedb0c30b1873ed660ca8d10cd" } ================================================ FILE: .sqlx/query-84e600f13d61c56a45133e7458d5152e68dec72030e5789bf4149a333b6ebdf5.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_template_view_template_category\n WHERE view_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "84e600f13d61c56a45133e7458d5152e68dec72030e5789bf4149a333b6ebdf5" } ================================================ FILE: .sqlx/query-852c729791d5b5eb2dde5772ccbcd24579486e43886d95a11481991fdf28efa8.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW())) ON CONFLICT (oid)\n DO UPDATE SET blob = $2, len = $3, owner_uid = $5, updated_at = COALESCE($7, NOW()) WHERE excluded.workspace_id = af_collab.workspace_id;\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Bytea", "Int4", "Int4", "Int8", "Uuid", "Timestamptz" ] }, "nullable": [] }, "hash": "852c729791d5b5eb2dde5772ccbcd24579486e43886d95a11481991fdf28efa8" } ================================================ FILE: .sqlx/query-85e9688218913dee85480932273ff6cf75d29af45638b195e73d73b6048806bf.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_workspace_member_profile (workspace_id, uid, name, avatar_url, cover_image_url, custom_image_url, description)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (workspace_id, uid) DO UPDATE\n SET name = EXCLUDED.name,\n avatar_url = EXCLUDED.avatar_url,\n cover_image_url = EXCLUDED.cover_image_url,\n custom_image_url = EXCLUDED.custom_image_url,\n description = EXCLUDED.description\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8", "Text", "Text", "Text", "Text", "Text" ] }, "nullable": [] }, "hash": "85e9688218913dee85480932273ff6cf75d29af45638b195e73d73b6048806bf" } ================================================ FILE: .sqlx/query-865fe86df6d04f8abb6d477af13f8a2392a742f4027d99c290f0f156df48be07.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n fragment_id,\n content_type,\n content,\n embedding as \"embedding!: Option\",\n metadata,\n fragment_index,\n embedder_type\n FROM af_collab_embeddings\n WHERE oid = $1\n ORDER BY fragment_index\n ", "describe": { "columns": [ { "ordinal": 0, "name": "fragment_id", "type_info": "Text" }, { "ordinal": 1, "name": "content_type", "type_info": "Int4" }, { "ordinal": 2, "name": "content", "type_info": "Text" }, { "ordinal": 3, "name": "embedding!: Option", "type_info": { "Custom": { "name": "vector", "kind": "Simple" } } }, { "ordinal": 4, "name": "metadata", "type_info": "Jsonb" }, { "ordinal": 5, "name": "fragment_index", "type_info": "Int4" }, { "ordinal": 6, "name": "embedder_type", "type_info": "Int2" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, true, true, true ] }, "hash": "865fe86df6d04f8abb6d477af13f8a2392a742f4027d99c290f0f156df48be07" } ================================================ FILE: .sqlx/query-87628d6739441a22229d08832d09cbf4598c36204a6885b2e279c848cedcfa75.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*)\n FROM af_published_collab\n WHERE workspace_id = $1\n AND view_id = ANY($2)\n AND published_by = (SELECT uid FROM af_user WHERE uuid = $3)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "UuidArray", "Uuid" ] }, "nullable": [ null ] }, "hash": "87628d6739441a22229d08832d09cbf4598c36204a6885b2e279c848cedcfa75" } ================================================ FILE: .sqlx/query-88516b9a2a424bc7697337d6f16b0d6e94b919597d709f930467423c5b4c0ec2.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT * FROM af_collab_snapshot\n WHERE workspace_id = $1 AND oid = $2 AND deleted_at IS NULL\n ORDER BY created_at DESC\n LIMIT 1;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "sid", "type_info": "Int8" }, { "ordinal": 1, "name": "oid", "type_info": "Text" }, { "ordinal": 2, "name": "blob", "type_info": "Bytea" }, { "ordinal": 3, "name": "len", "type_info": "Int4" }, { "ordinal": 4, "name": "encrypt", "type_info": "Int4" }, { "ordinal": 5, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, false, true, true, false, false ] }, "hash": "88516b9a2a424bc7697337d6f16b0d6e94b919597d709f930467423c5b4c0ec2" } ================================================ FILE: .sqlx/query-8cd79c307813a509119230c7673f86471463a06ad9a84764da8d5bb1e6168e1c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n af_user.uid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url' AS avatar_url,\n af_workspace_member.role_id AS role,\n af_workspace_member.created_at\n FROM public.af_workspace_member\n JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n AND role_id != $2\n ORDER BY af_workspace_member.created_at ASC;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 4, "name": "role", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false, false, false, null, false, true ] }, "hash": "8cd79c307813a509119230c7673f86471463a06ad9a84764da8d5bb1e6168e1c" } ================================================ FILE: .sqlx/query-90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id\n FROM af_workspace_invite_code\n WHERE invite_code = $1\n AND (expires_at IS NULL OR expires_at > NOW())\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false ] }, "hash": "90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de" } ================================================ FILE: .sqlx/query-90afca9cc8b6d4ca31e8ddf1ce466411b5034639df91b739f5cbe2af0ffb6811.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, fragment_id\n FROM af_collab_embeddings\n WHERE oid = ANY($1::uuid[])\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "fragment_id", "type_info": "Text" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, false ] }, "hash": "90afca9cc8b6d4ca31e8ddf1ce466411b5034639df91b739f5cbe2af0ffb6811" } ================================================ FILE: .sqlx/query-92c4d0e22b1f6f117c9f19589832f5f89cb5b903eee3c12f5e5fc0f70f3236e1.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_published_collab\n SET\n blob = E''::bytea,\n unpublished_at = NOW()\n WHERE workspace_id = $1\n AND view_id = ANY($2)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "UuidArray" ] }, "nullable": [] }, "hash": "92c4d0e22b1f6f117c9f19589832f5f89cb5b903eee3c12f5e5fc0f70f3236e1" } ================================================ FILE: .sqlx/query-936faba4e3c8fc3685d68f561a2c2d4f386c77cffde6f25702c19758a12669ce.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace_member\n SET\n role_id = $1\n WHERE workspace_id = $2 AND uid = (\n SELECT uid FROM af_user WHERE email = $3\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int4", "Uuid", "Text" ] }, "nullable": [] }, "hash": "936faba4e3c8fc3685d68f561a2c2d4f386c77cffde6f25702c19758a12669ce" } ================================================ FILE: .sqlx/query-93f6a59171d7cd08d321c777f24255621280fbcf6a2c009afd601eac16c9ba3a.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT view_id\n FROM af_published_collab\n WHERE workspace_id = $1\n AND unpublished_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "view_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "93f6a59171d7cd08d321c777f24255621280fbcf6a2c009afd601eac16c9ba3a" } ================================================ FILE: .sqlx/query-94555a25b986992bd3cfb67bd36ff015d39bdd78ac20d56570306616bf10faf3.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_published_collab (workspace_id, view_id, publish_name, published_by, metadata, blob, comments_enabled, duplicate_enabled)\n SELECT * FROM UNNEST(\n (SELECT array_agg((SELECT $1::uuid)) FROM generate_series(1, $9))::uuid[],\n $2::uuid[],\n $3::text[],\n (SELECT array_agg((SELECT uid FROM af_user WHERE uuid = $4)) FROM generate_series(1, $9))::bigint[],\n $5::jsonb[],\n $6::bytea[],\n $7::boolean[],\n $8::boolean[]\n )\n ON CONFLICT (workspace_id, view_id) DO UPDATE\n SET metadata = EXCLUDED.metadata,\n blob = EXCLUDED.blob,\n published_by = EXCLUDED.published_by,\n publish_name = EXCLUDED.publish_name\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "UuidArray", "TextArray", "Uuid", "JsonbArray", "ByteaArray", "BoolArray", "BoolArray", "Int4" ] }, "nullable": [] }, "hash": "94555a25b986992bd3cfb67bd36ff015d39bdd78ac20d56570306616bf10faf3" } ================================================ FILE: .sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_access_request\n SET status = $2\n WHERE request_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [] }, "hash": "95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d" } ================================================ FILE: .sqlx/query-95b4d7508569cac38c78d21a0a471772d3703e5678ee7ca0cd32d60f5343be91.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_chat_messages (chat_id, author, content)\n VALUES ($1, $2, $3)\n RETURNING message_id, created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "message_id", "type_info": "Int8" }, { "ordinal": 1, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Jsonb", "Text" ] }, "nullable": [ false, false ] }, "hash": "95b4d7508569cac38c78d21a0a471772d3703e5678ee7ca0cd32d60f5343be91" } ================================================ FILE: .sqlx/query-95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at AS last_updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n (au.uuid, au.name, au.email, au.metadata ->> 'icon_url') AS \"user: AFWebUserWithEmailColumn\",\n (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\n FROM af_published_view_comment avc\n LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\n WHERE view_id = $1\n ORDER BY avc.created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 2, "name": "last_updated_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "content", "type_info": "Text" }, { "ordinal": 4, "name": "reply_comment_id", "type_info": "Uuid" }, { "ordinal": 5, "name": "is_deleted", "type_info": "Bool" }, { "ordinal": 6, "name": "user: AFWebUserWithEmailColumn", "type_info": "Record" }, { "ordinal": 7, "name": "can_be_deleted!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Bool", "Uuid" ] }, "nullable": [ false, false, false, false, true, false, null, null ] }, "hash": "95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12" } ================================================ FILE: .sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_published_view_comment (view_id, created_by, content, reply_comment_id)\n VALUES ($1, (SELECT uid FROM af_user WHERE uuid = $2), $3, $4)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Text", "Uuid" ] }, "nullable": [] }, "hash": "9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b" } ================================================ FILE: .sqlx/query-9b2a8297fa991418b255fc5cb6ad70d695c4dceed20bdc557bfedfc820511126.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_template_category\n SET\n name = $2,\n description = $3,\n icon = $4,\n bg_color = $5,\n category_type = $6,\n priority = $7,\n updated_at = NOW()\n WHERE category_id = $1\n RETURNING\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n priority\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "description", "type_info": "Text" }, { "ordinal": 3, "name": "icon", "type_info": "Text" }, { "ordinal": 4, "name": "bg_color", "type_info": "Text" }, { "ordinal": 5, "name": "category_type: AFTemplateCategoryTypeColumn", "type_info": "Int4" }, { "ordinal": 6, "name": "priority", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Text", "Int4", "Int4" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "9b2a8297fa991418b255fc5cb6ad70d695c4dceed20bdc557bfedfc820511126" } ================================================ FILE: .sqlx/query-a18d0c9536dba734715903c8e8f0b7be30d3e7a477c4ddd03533b781df2fb2c7.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n awn.namespace,\n apc.publish_name,\n apc.view_id,\n au.email AS publisher_email,\n apc.created_at AS publish_timestamp,\n apc.unpublished_at AS unpublished_timestamp,\n apc.comments_enabled,\n apc.duplicate_enabled\n FROM af_published_collab apc\n JOIN af_user au ON apc.published_by = au.uid\n JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\n JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\n WHERE apc.workspace_id = $1 AND apc.unpublished_at IS NULL;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "namespace", "type_info": "Text" }, { "ordinal": 1, "name": "publish_name", "type_info": "Text" }, { "ordinal": 2, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "publisher_email", "type_info": "Text" }, { "ordinal": 4, "name": "publish_timestamp", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "unpublished_timestamp", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "comments_enabled", "type_info": "Bool" }, { "ordinal": 7, "name": "duplicate_enabled", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, false, false ] }, "hash": "a18d0c9536dba734715903c8e8f0b7be30d3e7a477c4ddd03533b781df2fb2c7" } ================================================ FILE: .sqlx/query-a3ab30d48e4a10aff1fbfa9dbc5d275a06598610bc471893c8c0febfc36c4737.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id > $2)", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ null ] }, "hash": "a3ab30d48e4a10aff1fbfa9dbc5d275a06598610bc471893c8c0febfc36c4737" } ================================================ FILE: .sqlx/query-a3c235bd5df50f80ec93c3d9f6da8db7e17e89788f30c5b6432c582992b6a009.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_published_collab\n WHERE workspace_id = $1\n AND publish_name = ANY($2::text[])\n RETURNING publish_name\n ", "describe": { "columns": [ { "ordinal": 0, "name": "publish_name", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid", "TextArray" ] }, "nullable": [ false ] }, "hash": "a3c235bd5df50f80ec93c3d9f6da8db7e17e89788f30c5b6432c582992b6a009" } ================================================ FILE: .sqlx/query-a527a90fcb69c58a5e711555b6ee56e7b92ceabe746279eccd7ae3e9fa918e96.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n af_user.uid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url' AS avatar_url,\n af_workspace_member.role_id AS role,\n af_workspace_member.created_at\n FROM public.af_workspace_member\n JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n AND af_workspace_member.uid = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 4, "name": "role", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false, false, false, null, false, true ] }, "hash": "a527a90fcb69c58a5e711555b6ee56e7b92ceabe746279eccd7ae3e9fa918e96" } ================================================ FILE: .sqlx/query-a75bf8b11d832d154716d4618595b117da583a31b51baaf7b84e9ee0d0e3109c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n person_id\n FROM af_page_mention\n WHERE workspace_id = $1\n AND mentioned_by = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "person_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false ] }, "hash": "a75bf8b11d832d154716d4618595b117da583a31b51baaf7b84e9ee0d0e3109c" } ================================================ FILE: .sqlx/query-a7c03becdf9954611ac7ad96e1f5bb5e8364f095f1cc4dc23719b218eb032973.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT deleted_at IS NOT NULL AS is_deleted\n FROM af_collab\n WHERE oid = $1;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "is_deleted", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "a7c03becdf9954611ac7ad96e1f5bb5e8364f095f1cc4dc23719b218eb032973" } ================================================ FILE: .sqlx/query-aa75996ca6aa12f0bcaa5fb092ac279f8a94aadcc29d0e2b652dc420506835e7.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, role_id\n FROM af_workspace_member\n WHERE workspace_id = ANY($1)\n AND uid = (SELECT uid FROM public.af_user WHERE uuid = $2)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "role_id", "type_info": "Int4" } ], "parameters": { "Left": [ "UuidArray", "Uuid" ] }, "nullable": [ false, false ] }, "hash": "aa75996ca6aa12f0bcaa5fb092ac279f8a94aadcc29d0e2b652dc420506835e7" } ================================================ FILE: .sqlx/query-b16f38d563d4d0b35f06978a8b2c76dc5121b0e59f8b5992c9dad05dd101c8ad.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n LOWER(i.invitee_email) = LOWER(u_invitee.email)\n AND ($2::SMALLINT IS NULL OR i.status = $2);\n ", "describe": { "columns": [ { "ordinal": 0, "name": "invite_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 3, "name": "inviter_email", "type_info": "Text" }, { "ordinal": 4, "name": "inviter_name", "type_info": "Text" }, { "ordinal": 5, "name": "status", "type_info": "Int2" }, { "ordinal": 6, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "inviter_icon", "type_info": "Text" }, { "ordinal": 8, "name": "workspace_icon", "type_info": "Text" }, { "ordinal": 9, "name": "member_count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "Int2" ] }, "nullable": [ false, false, true, false, false, false, false, null, false, null ] }, "hash": "b16f38d563d4d0b35f06978a8b2c76dc5121b0e59f8b5992c9dad05dd101c8ad" } ================================================ FILE: .sqlx/query-b5024138772e13557df973c1c021daf74aab97b5874d7366c478c18ae2e89e58.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT uid FROM af_user WHERE email = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false ] }, "hash": "b5024138772e13557df973c1c021daf74aab97b5874d7366c478c18ae2e89e58" } ================================================ FILE: .sqlx/query-b509712055858af398fd12ddd1a8c3da54280cf55f0c53f340bddbf4bf09b3e0.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_related_template_view (view_id, related_view_id)\n SELECT $1 AS view_id, related_view_id\n FROM UNNEST($2::uuid[]) AS t(related_view_id)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "UuidArray" ] }, "nullable": [] }, "hash": "b509712055858af398fd12ddd1a8c3da54280cf55f0c53f340bddbf4bf09b3e0" } ================================================ FILE: .sqlx/query-ba815f67aab3f302a2982225b72c6113bbd9bc87326e4f0a3b44dadbb5f47920.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT uid, role_id as role, workspace_id FROM af_workspace_member", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "role", "type_info": "Int4" }, { "ordinal": 2, "name": "workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [] }, "nullable": [ false, false, false ] }, "hash": "ba815f67aab3f302a2982225b72c6113bbd9bc87326e4f0a3b44dadbb5f47920" } ================================================ FILE: .sqlx/query-bbb3c31ea7e9c0a3bdabbc23b2730ee0254f38a7c1457f917c8f37f1e1aefa12.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_chat_messages\n SET reply_message_id = $2\n WHERE message_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", "Int8" ] }, "nullable": [] }, "hash": "bbb3c31ea7e9c0a3bdabbc23b2730ee0254f38a7c1457f917c8f37f1e1aefa12" } ================================================ FILE: .sqlx/query-bd34e351ea1adc0d12d4f1cce5a855089b7f39a431dea2903c3e0b9a220640b8.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH creator_number_of_templates AS (\n SELECT\n creator_id,\n COUNT(1)::int AS number_of_templates\n FROM af_template_view\n WHERE creator_id = $1\n GROUP BY creator_id\n )\n SELECT\n creator.creator_id AS \"id!\",\n name AS \"name!\",\n avatar_url AS \"avatar_url!\",\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec\",\n COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n FROM af_template_creator creator\n LEFT OUTER JOIN af_template_creator_account_link account_link\n USING (creator_id)\n LEFT OUTER JOIN creator_number_of_templates\n USING (creator_id)\n WHERE creator.creator_id = $1\n GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "avatar_url!", "type_info": "Text" }, { "ordinal": 3, "name": "account_links: Vec", "type_info": "RecordArray" }, { "ordinal": 4, "name": "number_of_templates!", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, null, null ] }, "hash": "bd34e351ea1adc0d12d4f1cce5a855089b7f39a431dea2903c3e0b9a220640b8" } ================================================ FILE: .sqlx/query-bde2b88ffb1b59362c7ae82369892c79131c175924f95e5d48d75931fb846f41.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT email FROM af_user WHERE uuid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "email", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "bde2b88ffb1b59362c7ae82369892c79131c175924f95e5d48d75931fb846f41" } ================================================ FILE: .sqlx/query-bf9bff5c65ba051329ed2b694eff62808f971a8262b6e1649d91526ab3a3870d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id, namespace, is_original\n FROM af_workspace_namespace\n WHERE workspace_id = $1\n AND namespace = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "namespace", "type_info": "Text" }, { "ordinal": 2, "name": "is_original", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false ] }, "hash": "bf9bff5c65ba051329ed2b694eff62808f971a8262b6e1649d91526ab3a3870d" } ================================================ FILE: .sqlx/query-c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_template_creator\n WHERE creator_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e" } ================================================ FILE: .sqlx/query-c360ec37792d567535ccd2a5011d92c7a201f516e92e204db855167f381c58b1.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n workspace_id,\n database_storage_id,\n owner_uid,\n owner_profile.name as owner_name,\n owner_profile.email as owner_email,\n af_workspace.created_at,\n workspace_type,\n af_workspace.deleted_at,\n workspace_name,\n icon\n FROM public.af_workspace\n JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\n WHERE af_workspace.workspace_id = $1\n AND COALESCE(af_workspace.is_initialized, true) = true;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "database_storage_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "owner_name", "type_info": "Text" }, { "ordinal": 4, "name": "owner_email", "type_info": "Text" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_type", "type_info": "Int4" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 9, "name": "icon", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, false, true, true, false ] }, "hash": "c360ec37792d567535ccd2a5011d92c7a201f516e92e204db855167f381c58b1" } ================================================ FILE: .sqlx/query-c43d414f6fcaed34e059f55abaaa0bd1343cacf4d04e98481a4787a4b965ce94.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace_namespace\n SET namespace = $1\n WHERE workspace_id = $2\n AND namespace = $3\n AND is_original = FALSE\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid", "Text" ] }, "nullable": [] }, "hash": "c43d414f6fcaed34e059f55abaaa0bd1343cacf4d04e98481a4787a4b965ce94" } ================================================ FILE: .sqlx/query-c81848346ed2ff85f1d5fb8041fba648137a927762b385b97054552c00793a50.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_workspace_invite_code\n WHERE workspace_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "c81848346ed2ff85f1d5fb8041fba648137a927762b385b97054552c00793a50" } ================================================ FILE: .sqlx/query-c843fb8517b1e364016b85a9e94927673bf8311bfbf723b610d59ecfef3fafce.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM public.af_workspace_member\n WHERE\n workspace_id = $1\n AND uid = (\n SELECT uid FROM public.af_user WHERE email = $2\n )\n -- Ensure the user to be deleted is not the original owner.\n -- 1. TODO(nathan): User must transfer ownership to another user first.\n -- 2. User must have at least one workspace\n AND uid <> (\n SELECT owner_uid FROM public.af_workspace WHERE workspace_id = $1\n );\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [] }, "hash": "c843fb8517b1e364016b85a9e94927673bf8311bfbf723b610d59ecfef3fafce" } ================================================ FILE: .sqlx/query-c8b1f57c5ddce8006a8e137be07f13b455f59657f5fcef67d69905ecec4cb063.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n w.workspace_name AS \"workspace_name!\",\n pm.workspace_id,\n pm.view_id,\n pm.view_name,\n mentioner.name AS \"mentioner_name!\",\n mentioner.metadata ->> 'icon_url' AS \"mentioner_avatar_url\",\n pm.person_id AS \"mentioned_person_id\",\n mentioned_person.name AS \"mentioned_person_name!\",\n mentioned_person.email AS \"mentioned_person_email!\",\n pm.mentioned_at AS \"mentioned_at!\",\n pm.block_id\n FROM af_page_mention AS pm\n JOIN af_workspace AS w ON pm.workspace_id = w.workspace_id\n JOIN af_user AS mentioned_person\n ON pm.person_id = mentioned_person.uuid\n JOIN af_user AS mentioner\n ON pm.mentioned_by = mentioner.uid\n WHERE pm.mentioned_at > NOW() - $1::INTERVAL\n AND require_notification\n AND NOT notified\n FOR UPDATE SKIP LOCKED\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_name!", "type_info": "Text" }, { "ordinal": 1, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "view_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "view_name", "type_info": "Text" }, { "ordinal": 4, "name": "mentioner_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mentioner_avatar_url", "type_info": "Text" }, { "ordinal": 6, "name": "mentioned_person_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "mentioned_person_name!", "type_info": "Text" }, { "ordinal": 8, "name": "mentioned_person_email!", "type_info": "Text" }, { "ordinal": 9, "name": "mentioned_at!", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "block_id", "type_info": "Text" } ], "parameters": { "Left": [ "Interval" ] }, "nullable": [ true, false, false, false, false, null, false, false, false, true, true ] }, "hash": "c8b1f57c5ddce8006a8e137be07f13b455f59657f5fcef67d69905ecec4cb063" } ================================================ FILE: .sqlx/query-ca2a21db67716e3f12b9f9240c1dba1b7cbe0bec1f59ef132fed53942ebad317.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_template_view (\n view_id,\n name,\n description,\n about,\n view_url,\n creator_id,\n is_new_template,\n is_featured\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Text", "Uuid", "Bool", "Bool" ] }, "nullable": [] }, "hash": "ca2a21db67716e3f12b9f9240c1dba1b7cbe0bec1f59ef132fed53942ebad317" } ================================================ FILE: .sqlx/query-cb2375ad0094baefed417645b781f40dcabfbfe4a4738c99bb4efff649e6a0e6.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_template_category (name, description, icon, bg_color, category_type, priority)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n priority\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "description", "type_info": "Text" }, { "ordinal": 3, "name": "icon", "type_info": "Text" }, { "ordinal": 4, "name": "bg_color", "type_info": "Text" }, { "ordinal": 5, "name": "category_type: AFTemplateCategoryTypeColumn", "type_info": "Int4" }, { "ordinal": 6, "name": "priority", "type_info": "Int4" } ], "parameters": { "Left": [ "Text", "Text", "Text", "Text", "Int4", "Int4" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "cb2375ad0094baefed417645b781f40dcabfbfe4a4738c99bb4efff649e6a0e6" } ================================================ FILE: .sqlx/query-cbe8402053d42529dce158b446d09a00982e1d7cdc33835776bfbefb4b4c1854.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT view_id\n FROM af_published_collab\n WHERE workspace_id = $1\n AND unpublished_at IS NULL\n AND publish_name = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "view_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false ] }, "hash": "cbe8402053d42529dce158b446d09a00982e1d7cdc33835776bfbefb4b4c1854" } ================================================ FILE: .sqlx/query-cbf1d3d9fdeb672eacd4b008879787bc1f0b22a554fb249d4e12a665d9767cbd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n ac.oid as object_id,\n ace.partition_key,\n ac.indexed_at,\n ace.updated_at\n FROM af_collab_embeddings ac\n JOIN af_collab ace ON ac.oid = ace.oid\n WHERE ac.oid = ANY($1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "object_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "partition_key", "type_info": "Int4" }, { "ordinal": 2, "name": "indexed_at", "type_info": "Timestamp" }, { "ordinal": 3, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, false, false, false ] }, "hash": "cbf1d3d9fdeb672eacd4b008879787bc1f0b22a554fb249d4e12a665d9767cbd" } ================================================ FILE: .sqlx/query-cce2abeed3399ad0b8867901735c5883c8d35fa82d6e0596c56eaf02c36a7e4f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, deleted_at IS NOT NULL AS is_deleted\n FROM af_collab\n WHERE oid = ANY($1);\n ", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Uuid" }, { "ordinal": 1, "name": "is_deleted", "type_info": "Bool" } ], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [ false, null ] }, "hash": "cce2abeed3399ad0b8867901735c5883c8d35fa82d6e0596c56eaf02c36a7e4f" } ================================================ FILE: .sqlx/query-d0a24b554fe420d7ebf856ae7f1525aff3695fc97e2f43041dc54a4e62a88746.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM af_blob_metadata\n WHERE workspace_id = $1 AND file_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [] }, "hash": "d0a24b554fe420d7ebf856ae7f1525aff3695fc97e2f43041dc54a4e62a88746" } ================================================ FILE: .sqlx/query-d0e5f5097b35a15f19e9e7faf2c62336d5f130e939331e84c7d834f6028ea673.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_collab\n SET indexed_at = $1\n WHERE oid = $2 AND partition_key = $3\n ", "describe": { "columns": [], "parameters": { "Left": [ "Timestamptz", "Uuid", "Int4" ] }, "nullable": [] }, "hash": "d0e5f5097b35a15f19e9e7faf2c62336d5f130e939331e84c7d834f6028ea673" } ================================================ FILE: .sqlx/query-d1ab621e0b6e8bc24f8fa8cbb975ae3b7f9f366cac02d66b5291d7207295ca29.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT message_id, content, created_at, author, meta_data, reply_message_id\n FROM af_chat_messages\n WHERE message_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "message_id", "type_info": "Int8" }, { "ordinal": 1, "name": "content", "type_info": "Text" }, { "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "author", "type_info": "Jsonb" }, { "ordinal": 4, "name": "meta_data", "type_info": "Jsonb" }, { "ordinal": 5, "name": "reply_message_id", "type_info": "Int8" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false, false, false, false, true ] }, "hash": "d1ab621e0b6e8bc24f8fa8cbb975ae3b7f9f366cac02d66b5291d7207295ca29" } ================================================ FILE: .sqlx/query-d1f845717b19636e61d1d96d7a5629754f3ded9bda9116953bd1b40bd80551ae.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\n VALUES ($1, $2, $3, $4, $5)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Bytea", "Int4", "Int4", "Uuid" ] }, "nullable": [] }, "hash": "d1f845717b19636e61d1d96d7a5629754f3ded9bda9116953bd1b40bd80551ae" } ================================================ FILE: .sqlx/query-d2e87c077e5702cd57a88e23e1eabe4b0badd98ef99da1b185bffa8d5c9ed298.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id < $2)", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ null ] }, "hash": "d2e87c077e5702cd57a88e23e1eabe4b0badd98ef99da1b185bffa8d5c9ed298" } ================================================ FILE: .sqlx/query-d366aca6b187f086e5a8281081adec190bbb3cd5256c5a77ed321b99cd34bbbc.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n af_user.uid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url' AS avatar_url,\n af_workspace_member.role_id AS role,\n af_workspace_member.created_at\n FROM public.af_workspace_member\n JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n AND af_user.uuid = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 4, "name": "role", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, false, false, null, false, true ] }, "hash": "d366aca6b187f086e5a8281081adec190bbb3cd5256c5a77ed321b99cd34bbbc" } ================================================ FILE: .sqlx/query-d388782f755f0b164ef36c168af142baeb9bbd3cc2b8b7cd736b346580be8790.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS (SELECT 1 FROM af_collab WHERE oid = $1 LIMIT 1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "d388782f755f0b164ef36c168af142baeb9bbd3cc2b8b7cd736b346580be8790" } ================================================ FILE: .sqlx/query-d492c20dec54c7335744dcc139b95f30a80f06d9fd48de644630adf183e1ac34.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*)\n FROM public.af_workspace_member\n WHERE workspace_id = $1\n AND role_id != $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ null ] }, "hash": "d492c20dec54c7335744dcc139b95f30a80f06d9fd48de644630adf183e1ac34" } ================================================ FILE: .sqlx/query-d4fa2c5f3c455be4694235009e82efdd99d366e3b0374f78efec8dd560f88d95.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT uid\n FROM af_workspace_member\n WHERE workspace_id = $1\n ORDER BY created_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "d4fa2c5f3c455be4694235009e82efdd99d366e3b0374f78efec8dd560f88d95" } ================================================ FILE: .sqlx/query-d61523de25986b47a382d36a1f18e590420f1b1285d024f5554cc02c375d6476.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_user\n WHERE uuid = $1\n ) AS user_exists;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "user_exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "d61523de25986b47a382d36a1f18e590420f1b1285d024f5554cc02c375d6476" } ================================================ FILE: .sqlx/query-d756ec630d5b75dd0dc7df2339847e28bdf07a790e65fd40a64d7f9022f430bd.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE public.af_workspace\n SET icon = $1\n WHERE workspace_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid" ] }, "nullable": [] }, "hash": "d756ec630d5b75dd0dc7df2339847e28bdf07a790e65fd40a64d7f9022f430bd" } ================================================ FILE: .sqlx/query-d84ab58e78653688e7c392ffad00d6e039be5ccb9c5b99b7088cc41cfe981873.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT message_id, content, created_at, author, meta_data, reply_message_id\n FROM af_chat_messages\n WHERE chat_id = $1\n ORDER BY created_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "message_id", "type_info": "Int8" }, { "ordinal": 1, "name": "content", "type_info": "Text" }, { "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "author", "type_info": "Jsonb" }, { "ordinal": 4, "name": "meta_data", "type_info": "Jsonb" }, { "ordinal": 5, "name": "reply_message_id", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true ] }, "hash": "d84ab58e78653688e7c392ffad00d6e039be5ccb9c5b99b7088cc41cfe981873" } ================================================ FILE: .sqlx/query-d90e7efaca54b92de038b6eef20a7bd36be747dc38f7943fe299799c623038be.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n af_user.uid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url' AS avatar_url,\n af_workspace_member.role_id AS role,\n af_workspace_member.created_at\n FROM public.af_workspace_member\n JOIN public.af_workspace USING(workspace_id)\n JOIN public.af_user ON af_workspace.owner_uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uid", "type_info": "Int8" }, { "ordinal": 1, "name": "name", "type_info": "Text" }, { "ordinal": 2, "name": "email", "type_info": "Text" }, { "ordinal": 3, "name": "avatar_url", "type_info": "Text" }, { "ordinal": 4, "name": "role", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, null, false, true ] }, "hash": "d90e7efaca54b92de038b6eef20a7bd36be747dc38f7943fe299799c623038be" } ================================================ FILE: .sqlx/query-d921f52e4bc3fef72c810e19455a2fa4fbd52f5a1f3a1838b146d001eadabd47.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace_member\n SET updated_at = CURRENT_TIMESTAMP\n WHERE uid = (SELECT uid FROM public.af_user WHERE uuid = $1) AND workspace_id = $2;\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "d921f52e4bc3fef72c810e19455a2fa4fbd52f5a1f3a1838b146d001eadabd47" } ================================================ FILE: .sqlx/query-da1434fe116cbb48bc5aac0b6905dd748f096bf78d3cdcfea3a576b4aaeba5fc.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_chat_messages\n SET content = $2,\n author = $3,\n created_at = CURRENT_TIMESTAMP,\n meta_data = $4\n WHERE message_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", "Text", "Jsonb", "Jsonb" ] }, "nullable": [] }, "hash": "da1434fe116cbb48bc5aac0b6905dd748f096bf78d3cdcfea3a576b4aaeba5fc" } ================================================ FILE: .sqlx/query-dbc31936b3e79632f9c8bae449182274d9d75766bd9a5c383b96bd60e9c5c866.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT rag_ids\n FROM af_chat\n WHERE chat_id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "rag_ids", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "dbc31936b3e79632f9c8bae449182274d9d75766bd9a5c383b96bd60e9c5c866" } ================================================ FILE: .sqlx/query-dc600fc160b55be22fb77e285fd7e5e646ef359fdbca9b62c6aefede5ebff606.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH recent_template AS (\n SELECT\n template_template_category.category_id,\n template_template_category.view_id,\n category.name,\n category.icon,\n category.bg_color,\n ROW_NUMBER() OVER (PARTITION BY template_template_category.category_id ORDER BY template.created_at DESC) AS recency\n FROM af_template_view_template_category template_template_category\n JOIN af_template_category category\n USING (category_id)\n JOIN af_template_view template\n USING (view_id)\n JOIN af_published_collab\n USING (view_id)\n ),\n template_group_by_category_and_view AS (\n SELECT\n category_id,\n view_id,\n ARRAY_AGG((\n category_id,\n name,\n icon,\n bg_color\n )::template_category_minimal_type) AS categories\n FROM recent_template\n WHERE recency <= $1\n GROUP BY category_id, view_id\n ),\n template_group_by_category_and_view_with_creator_and_template_details AS (\n SELECT\n template_group_by_category_and_view.category_id,\n (\n template.view_id,\n template.created_at,\n template.updated_at,\n template.name,\n template.description,\n template.view_url,\n (\n creator.creator_id,\n creator.name,\n creator.avatar_url\n )::template_creator_minimal_type,\n template_group_by_category_and_view.categories,\n template.is_new_template,\n template.is_featured\n )::template_minimal_type AS template\n FROM template_group_by_category_and_view\n JOIN af_template_view template\n USING (view_id)\n JOIN af_template_creator creator\n USING (creator_id)\n ),\n template_group_by_category AS (\n SELECT\n category_id,\n ARRAY_AGG(template) AS templates\n FROM template_group_by_category_and_view_with_creator_and_template_details\n GROUP BY category_id\n )\n SELECT\n (\n template_group_by_category.category_id,\n category.name,\n category.icon,\n category.bg_color\n )::template_category_minimal_type AS \"category!: AFTemplateCategoryMinimalRow\",\n templates AS \"templates!: Vec\"\n FROM template_group_by_category\n JOIN af_template_category category\n USING (category_id)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "category!: AFTemplateCategoryMinimalRow", "type_info": { "Custom": { "name": "template_category_minimal_type", "kind": { "Composite": [ [ "category_id", "Uuid" ], [ "name", "Text" ], [ "icon", "Text" ], [ "bg_color", "Text" ] ] } } } }, { "ordinal": 1, "name": "templates!: Vec", "type_info": { "Custom": { "name": "template_minimal_type[]", "kind": { "Array": { "Custom": { "name": "template_minimal_type", "kind": { "Composite": [ [ "view_id", "Uuid" ], [ "created_at", "Timestamptz" ], [ "updated_at", "Timestamptz" ], [ "name", "Text" ], [ "description", "Text" ], [ "view_url", "Text" ], [ "creator", { "Custom": { "name": "template_creator_minimal_type", "kind": { "Composite": [ [ "creator_id", "Uuid" ], [ "name", "Text" ], [ "avatar_url", "Text" ] ] } } } ], [ "categories", { "Custom": { "name": "template_category_minimal_type[]", "kind": { "Array": { "Custom": { "name": "template_category_minimal_type", "kind": { "Composite": [ [ "category_id", "Uuid" ], [ "name", "Text" ], [ "icon", "Text" ], [ "bg_color", "Text" ] ] } } } } } } ], [ "is_new_template", "Bool" ], [ "is_featured", "Bool" ] ] } } } } } } } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ null, null ] }, "hash": "dc600fc160b55be22fb77e285fd7e5e646ef359fdbca9b62c6aefede5ebff606" } ================================================ FILE: .sqlx/query-e219696c80f1d4c38260ebeb50ec78e344975eef6760951dbf6201c01b8ceef0.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE public.af_workspace_invitation\n SET status = 1\n WHERE LOWER(invitee_email) = (SELECT LOWER(email) FROM public.af_user WHERE uuid = $1)\n AND id = $2\n AND status = 0\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "e219696c80f1d4c38260ebeb50ec78e344975eef6760951dbf6201c01b8ceef0" } ================================================ FILE: .sqlx/query-e2b4d66736962d1e3d0b9cf687ce5c5e653b465462f53433a28cf314e5c87d6c.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH ins_user AS (\n INSERT INTO af_user (uid, uuid, email, name)\n VALUES ($1, $2, $3, $4)\n RETURNING uid\n ),\n owner_role AS (\n SELECT id FROM af_roles WHERE name = 'Owner'\n ),\n ins_workspace AS (\n INSERT INTO af_workspace (owner_uid)\n SELECT uid FROM ins_user\n RETURNING workspace_id, owner_uid\n )\n SELECT workspace_id FROM ins_workspace;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Int8", "Uuid", "Text", "Text" ] }, "nullable": [ false ] }, "hash": "e2b4d66736962d1e3d0b9cf687ce5c5e653b465462f53433a28cf314e5c87d6c" } ================================================ FILE: .sqlx/query-e38e66d89806471f358b317778de35a68da4b9e6ca6e4b6a7c437ca7493b858c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT uuid FROM af_user WHERE uid = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false ] }, "hash": "e38e66d89806471f358b317778de35a68da4b9e6ca6e4b6a7c437ca7493b858c" } ================================================ FILE: .sqlx/query-e6159a03f1521b44de59858cd95c48e62cabefba6cac629c104eec75d2868bf3.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH user_workspace_id AS (\n SELECT workspace_id\n FROM af_workspace_member\n JOIN af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_user.uuid = $1\n ),\n workspace_member_count AS (\n SELECT\n workspace_id,\n COUNT(*) AS member_count\n FROM af_workspace_member\n JOIN user_workspace_id USING (workspace_id)\n WHERE role_id != $2\n GROUP BY workspace_id\n )\n\n SELECT\n w.workspace_id,\n w.database_storage_id,\n w.owner_uid,\n u.name AS owner_name,\n u.email AS owner_email,\n w.created_at,\n w.workspace_type,\n w.deleted_at,\n w.workspace_name,\n w.icon,\n wmc.member_count AS \"member_count!\",\n wm.role_id AS \"role!\"\n FROM af_workspace w\n JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n JOIN public.af_user u ON w.owner_uid = u.uid\n JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\n WHERE wm.uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n )\n AND wm.role_id != $2\n AND COALESCE(w.is_initialized, true) = true;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "database_storage_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_uid", "type_info": "Int8" }, { "ordinal": 3, "name": "owner_name", "type_info": "Text" }, { "ordinal": 4, "name": "owner_email", "type_info": "Text" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "workspace_type", "type_info": "Int4" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 9, "name": "icon", "type_info": "Text" }, { "ordinal": 10, "name": "member_count!", "type_info": "Int8" }, { "ordinal": 11, "name": "role!", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Int4" ] }, "nullable": [ false, false, false, false, false, true, false, true, true, false, null, false ] }, "hash": "e6159a03f1521b44de59858cd95c48e62cabefba6cac629c104eec75d2868bf3" } ================================================ FILE: .sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_published_view_comment\n SET is_deleted = true\n WHERE comment_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4" } ================================================ FILE: .sqlx/query-ea239353f73904400915ec89640ac71985a8d5b39037f567a3e2ac1c5eea8f64.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_workspace_namespace\n VALUES ($1, $2, FALSE)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid" ] }, "nullable": [] }, "hash": "ea239353f73904400915ec89640ac71985a8d5b39037f567a3e2ac1c5eea8f64" } ================================================ FILE: .sqlx/query-eb142b33bd6d0d9f3ceb597be9251eac710a463d1052ba10c41b207dbf63efe1.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT workspace_id\n FROM af_workspace_namespace\n WHERE namespace = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false ] }, "hash": "eb142b33bd6d0d9f3ceb597be9251eac710a463d1052ba10c41b207dbf63efe1" } ================================================ FILE: .sqlx/query-ed9bce7f35c4dd8d41427bc56db67adf175044a8d31149b3745ceb8f9b3c82fa.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT oid, snapshot, snapshot_version, created_at\n FROM af_snapshot_meta\n WHERE oid = $1 AND partition_key = $2\n ORDER BY created_at DESC", "describe": { "columns": [ { "ordinal": 0, "name": "oid", "type_info": "Text" }, { "ordinal": 1, "name": "snapshot", "type_info": "Bytea" }, { "ordinal": 2, "name": "snapshot_version", "type_info": "Int4" }, { "ordinal": 3, "name": "created_at", "type_info": "Int8" } ], "parameters": { "Left": [ "Text", "Int4" ] }, "nullable": [ false, false, false, false ] }, "hash": "ed9bce7f35c4dd8d41427bc56db67adf175044a8d31149b3745ceb8f9b3c82fa" } ================================================ FILE: .sqlx/query-ef947984b00fdd32271e7e76d8b5d035cd4ca211b600787fda18d62a34b4c04b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM public.af_workspace_member\n JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n WHERE af_workspace_member.workspace_id = $1\n AND LOWER(af_user.email) = LOWER($2)\n ) AS \"exists\";\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ null ] }, "hash": "ef947984b00fdd32271e7e76d8b5d035cd4ca211b600787fda18d62a34b4c04b" } ================================================ FILE: .sqlx/query-f05042dd22f862603e63f63d47b93e579545c79cabe15d32304a47ca7665a55f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT p.id, p.name, p.access_level, p.description FROM af_permissions p\n JOIN af_role_permissions rp ON p.id = rp.permission_id\n WHERE rp.role_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Int4" }, { "ordinal": 1, "name": "name", "type_info": "Varchar" }, { "ordinal": 2, "name": "access_level", "type_info": "Int4" }, { "ordinal": 3, "name": "description", "type_info": "Text" } ], "parameters": { "Left": [ "Int4" ] }, "nullable": [ false, false, false, true ] }, "hash": "f05042dd22f862603e63f63d47b93e579545c79cabe15d32304a47ca7665a55f" } ================================================ FILE: .sqlx/query-f18d6e075a522b0ce5935351dd57ab0dda4d8b4ed3881c2ad0bc09c07c43e6fe.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING sid AS snapshot_id, oid AS object_id, created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "snapshot_id", "type_info": "Int8" }, { "ordinal": 1, "name": "object_id", "type_info": "Text" }, { "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Bytea", "Int4", "Int4", "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "f18d6e075a522b0ce5935351dd57ab0dda4d8b4ed3881c2ad0bc09c07c43e6fe" } ================================================ FILE: .sqlx/query-f409626142553d4496d15b5dfa7da8a5a238da86f56c930c09a261f2efa1f55c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT sid as \"snapshot_id\", oid as \"object_id\", created_at\n FROM af_collab_snapshot\n WHERE oid = $1 AND deleted_at IS NULL\n ORDER BY created_at DESC;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "snapshot_id", "type_info": "Int8" }, { "ordinal": 1, "name": "object_id", "type_info": "Text" }, { "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false ] }, "hash": "f409626142553d4496d15b5dfa7da8a5a238da86f56c930c09a261f2efa1f55c" } ================================================ FILE: .sqlx/query-f54ced785b4fdd22c9236b566996d5d9d4a8c91902e4029fe8f8f30f3af39b39.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO af_template_view_template_category (view_id, category_id)\n SELECT $1 as view_id, category_id FROM\n UNNEST($2::uuid[]) AS category_id\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "UuidArray" ] }, "nullable": [] }, "hash": "f54ced785b4fdd22c9236b566996d5d9d4a8c91902e4029fe8f8f30f3af39b39" } ================================================ FILE: .sqlx/query-f58a2f05efbda0698d27d83be5c6816fc46e3de33f926c6343bcbfa90a387b07.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM public.af_workspace\n WHERE workspace_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "f58a2f05efbda0698d27d83be5c6816fc46e3de33f926c6343bcbfa90a387b07" } ================================================ FILE: .sqlx/query-f68cc2042d6aa78feeb33640e9ef13f46c5e10ee269ea0bd965b0e57dee6cf94.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT c.workspace_id, c.oid, c.partition_key\n FROM af_collab c\n JOIN af_workspace w ON c.workspace_id = w.workspace_id\n WHERE c.workspace_id = $1\n AND NOT COALESCE(w.settings['disable_search_indexing']::boolean, false)\n AND c.indexed_at IS NULL\n ORDER BY c.updated_at DESC\n LIMIT $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "oid", "type_info": "Uuid" }, { "ordinal": 2, "name": "partition_key", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false, false, false ] }, "hash": "f68cc2042d6aa78feeb33640e9ef13f46c5e10ee269ea0bd965b0e57dee6cf94" } ================================================ FILE: .sqlx/query-f78c2c56568dcee0b93e759ee517fb87d6d115a02856a756d481ea4c863c0327.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT snapshot_id, oid, doc_state, doc_state_version, deps_snapshot_id, created_at\n FROM af_snapshot_state\n WHERE oid = $1 AND partition_key = $2 AND created_at >= $3\n ORDER BY created_at ASC\n LIMIT 1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "snapshot_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "oid", "type_info": "Text" }, { "ordinal": 2, "name": "doc_state", "type_info": "Bytea" }, { "ordinal": 3, "name": "doc_state_version", "type_info": "Int4" }, { "ordinal": 4, "name": "deps_snapshot_id", "type_info": "Uuid" }, { "ordinal": 5, "name": "created_at", "type_info": "Int8" } ], "parameters": { "Left": [ "Text", "Int4", "Int8" ] }, "nullable": [ false, false, false, false, true, false ] }, "hash": "f78c2c56568dcee0b93e759ee517fb87d6d115a02856a756d481ea4c863c0327" } ================================================ FILE: .sqlx/query-f9c28d0fa124ef543259c6869d7c517deabda3af9a67c6e59d8e15c0245c83a0.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT default_published_view_id\n FROM af_workspace\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "default_published_view_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ true ] }, "hash": "f9c28d0fa124ef543259c6869d7c517deabda3af9a67c6e59d8e15c0245c83a0" } ================================================ FILE: .sqlx/query-fa92aff963d9a0c69fb203f76f54728c67d52a68eada59ba3bd445c4b8aeceef.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_workspace_invitation\n WHERE id = $1 AND LOWER(invitee_email) = (SELECT LOWER(email) FROM af_user WHERE uuid = $2)\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "fa92aff963d9a0c69fb203f76f54728c67d52a68eada59ba3bd445c4b8aeceef" } ================================================ FILE: .sqlx/query-faf37892741717680e9a8d8e7d8decaba571d0dd129b57334aad7c63e2a2ef59.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n LOWER(i.invitee_email) = LOWER(u_invitee.email)\n AND i.id = $2;\n ", "describe": { "columns": [ { "ordinal": 0, "name": "invite_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "workspace_name", "type_info": "Text" }, { "ordinal": 3, "name": "inviter_email", "type_info": "Text" }, { "ordinal": 4, "name": "inviter_name", "type_info": "Text" }, { "ordinal": 5, "name": "status", "type_info": "Int2" }, { "ordinal": 6, "name": "updated_at", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "inviter_icon", "type_info": "Text" }, { "ordinal": 8, "name": "workspace_icon", "type_info": "Text" }, { "ordinal": 9, "name": "member_count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, false, true, false, false, false, false, null, false, null ] }, "hash": "faf37892741717680e9a8d8e7d8decaba571d0dd129b57334aad7c63e2a2ef59" } ================================================ FILE: .sqlx/query-fb21df2827de97055cdc1c493b079b29667f75b18169c909c4c8341697fd0105.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT *\n FROM af_chat\n WHERE chat_id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "chat_id", "type_info": "Uuid" }, { "ordinal": 1, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 2, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "name", "type_info": "Text" }, { "ordinal": 4, "name": "rag_ids", "type_info": "Jsonb" }, { "ordinal": 5, "name": "workspace_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "meta_data", "type_info": "Jsonb" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, false, false, false, false ] }, "hash": "fb21df2827de97055cdc1c493b079b29667f75b18169c909c4c8341697fd0105" } ================================================ FILE: .sqlx/query-fd2a37dd917717a9bb5e1db84f03f0e84e32d2fd081955389561c6567896ea9f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT blob\n FROM af_published_collab\n WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n AND unpublished_at IS NULL\n AND publish_name = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "blob", "type_info": "Bytea" } ], "parameters": { "Left": [ "Text", "Text" ] }, "nullable": [ false ] }, "hash": "fd2a37dd917717a9bb5e1db84f03f0e84e32d2fd081955389561c6567896ea9f" } ================================================ FILE: .sqlx/query-fffe6f01abf0e5d8649a49b5793ccb92a9f823f07c363341357ea74bf4f4a16d.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE af_workspace\n SET default_published_view_id = NULL\n WHERE workspace_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "fffe6f01abf0e5d8649a49b5793ccb92a9f823f07c363341357ea74bf4f4a16d" } ================================================ FILE: Cargo.toml ================================================ [package] name = "appflowy-cloud" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] actix.workspace = true actix-web.workspace = true actix-http = { workspace = true, default-features = false, features = [ "openssl", "compress-brotli", "compress-gzip", ] } actix-rt = "2.9.0" actix-web-actors = { version = "4.3" } actix-service = "2.0.2" actix-identity = "0.6.0" actix-session = { version = "0.8", features = ["redis-rs-tls-session"] } actix-multipart = { version = "0.7.2", features = ["derive"] } zstd.workspace = true # serde serde_json.workspace = true serde_repr.workspace = true serde.workspace = true tokio = { workspace = true, features = [ "macros", "rt-multi-thread", "sync", "fs", "time", "full", ] } tokio-stream.workspace = true tokio-util = { version = "0.7.10", features = ["io"] } futures-util = { workspace = true, features = ["std", "io"] } chrono.workspace = true secrecy.workspace = true rand = { version = "0.8", features = ["std_rng"] } anyhow.workspace = true reqwest = { workspace = true, features = [ "json", "rustls-tls", "cookies", "stream", ] } unicode-segmentation = "1.10" lazy_static.workspace = true fancy-regex = "0.11.0" bytes.workspace = true validator.workspace = true mime = "0.3.17" aws-sdk-s3 = { version = "1.88.0", features = [ "behavior-version-latest", "rt-tokio", ] } redis = { workspace = true, features = [ "json", "tokio-comp", "connection-manager", "aio", "bytes", "uuid", ] } tracing = { version = "0.1.40", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = [ "registry", "env-filter", "ansi", "json", "tracing-log", ] } sqlx = { workspace = true, default-features = false, features = [ "runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate", ] } async-trait.workspace = true prometheus-client.workspace = true itertools = "0.11" uuid.workspace = true tokio-tungstenite = { version = "0.26.1", features = ["native-tls"] } dotenvy.workspace = true brotli.workspace = true dashmap.workspace = true async-stream.workspace = true futures.workspace = true semver = "1.0.22" tonic.workspace = true prost.workspace = true tonic-proto.workspace = true appflowy-collaborate = { path = "services/appflowy-collaborate" } # ai appflowy-ai-client = { workspace = true, features = ["dto", "client-api"] } collab = { workspace = true, features = ["lock_timeout"] } collab-document = { workspace = true } collab-entity = { workspace = true } collab-folder = { workspace = true } collab-user = { workspace = true } collab-database = { workspace = true } collab-importer = { workspace = true } collab-rt-protocol.workspace = true #Local crate snowflake = { path = "libs/snowflake" } database.workspace = true database-entity.workspace = true gotrue = { path = "libs/gotrue" } gotrue-entity = { path = "libs/gotrue-entity" } infra = { path = "libs/infra" } access-control.workspace = true app-error = { workspace = true, features = [ "sqlx_error", "actix_web_error", "tokio_error", "appflowy_ai_error", ] } shared-entity = { path = "libs/shared-entity", features = ["cloud"] } workspace-template = { workspace = true } collab-rt-entity.workspace = true collab-stream.workspace = true yrs.workspace = true pin-project.workspace = true byteorder = "1.5.0" sha2 = "0.10.8" rayon.workspace = true mailer.workspace = true async_zip.workspace = true sanitize-filename.workspace = true futures-lite = "2.3.0" console-subscriber = { version = "0.4.1", optional = true } base64.workspace = true md5.workspace = true nanoid = "0.4.0" indexer.workspace = true llm-client.workspace = true async-openai.workspace = true appflowy-proto.workspace = true actix-cors = { version = "0.7.0", optional = true } [dev-dependencies] flate2 = "1.0" assert-json-diff = "2.0.2" client-api-test = { path = "libs/client-api-test", features = [] } client-api = { path = "libs/client-api", features = [ "test_util", "sync_verbose_log", "test_fast_sync", "enable_brotli", ] } collab-rt-entity = { path = "libs/collab-rt-entity" } hex = "0.4.3" async-openai.workspace = true [[bin]] name = "appflowy_cloud" path = "src/main.rs" [lib] path = "src/lib.rs" #[[bench]] #name = "access_control_benchmark" #harness = false [workspace] members = [ # libs "libs/snowflake", "libs/collab-rt-entity", "libs/database", "libs/database-entity", "libs/client-api", "libs/infra", "libs/shared-entity", "libs/gotrue", "libs/gotrue-entity", "admin_frontend", "libs/app-error", "libs/workspace-template", "libs/access-control", "libs/collab-rt-protocol", "libs/collab-stream", "libs/client-websocket", "libs/client-api-test", "libs/appflowy-ai-client", "libs/client-api-entity", # services "services/appflowy-collaborate", "services/appflowy-worker", # xtask "xtask", "libs/tonic-proto", "libs/mailer", "libs/indexer", "libs/llm-client", "libs/appflowy-proto", ] [workspace.dependencies] indexer = { path = "libs/indexer" } collab-rt-entity = { path = "libs/collab-rt-entity" } collab-rt-protocol = { path = "libs/collab-rt-protocol" } database = { path = "libs/database" } database-entity = { path = "libs/database-entity" } shared-entity = { path = "libs/shared-entity" } gotrue-entity = { path = "libs/gotrue-entity" } access-control = { path = "libs/access-control" } llm-client = { path = "libs/llm-client" } appflowy-proto = { path = "libs/appflowy-proto" } mailer = { path = "libs/mailer" } app-error = { path = "libs/app-error" } async-trait = "0.1.77" prometheus-client = "0.22.0" brotli = "3.4.0" collab-stream = { path = "libs/collab-stream" } dotenvy = "0.15.7" serde_json = "1.0.111" serde_repr = "0.1.18" serde = { version = "1.0", features = ["derive"] } bytes = "1.9.0" workspace-template = { path = "libs/workspace-template" } uuid = { version = "1.6.1", features = ["v4", "v5"] } anyhow = "1.0.94" actix = "0.13.3" actix-web = { version = "4.5.1", default-features = false, features = [ "openssl", "compress-brotli", "compress-gzip", ] } actix-http = { version = "3.6.0", default-features = false } tokio = { version = "1.36.0", features = ["sync"] } tokio-stream = "0.1.14" rayon = "1.10.0" futures-util = "0.3.30" bincode = "1.3.3" client-websocket = { path = "libs/client-websocket" } infra = { path = "libs/infra" } tracing = { version = "0.1", features = ["log"] } gotrue = { path = "libs/gotrue" } redis = "0.29" sqlx = { version = "0.8.1", default-features = false } dashmap = "5.5.3" futures = "0.3.30" async-stream = "0.3.5" reqwest = "0.12.9" lazy_static = "1.4.0" tonic = "0.12.3" prost = "0.13.3" tonic-proto = { path = "libs/tonic-proto" } appflowy-ai-client = { path = "libs/appflowy-ai-client", default-features = false } pgvector = { version = "0.4", features = ["sqlx"] } client-api-entity = { path = "libs/client-api-entity" } async_zip = { version = "0.0.17", features = ["full"] } sanitize-filename = "0.5.0" base64 = "0.22" md5 = "0.7.0" pin-project = "1.1.5" arc-swap = { version = "1.7" } validator = "0.19" zstd = { version = "0.13.2", features = [] } chrono = { version = "0.4.39", features = [ "serde", "clock", ], default-features = false } http = "0.2.12" tokio-tungstenite = "0.20" secrecy = { version = "0.8.0", features = ["serde"] } thiserror = "2.0" # collaboration yrs = { version = "0.23.5", features = ["sync"] } collab = { version = "0.2.0" } collab-entity = { version = "0.2.0" } collab-folder = { version = "0.2.0" } collab-document = { version = "0.2.0" } collab-database = { version = "0.2.0" } collab-user = { version = "0.2.0" } collab-importer = { version = "0.1.0" } async-openai = "0.28.0" collab-plugins = { version = "0.2.0" } smallvec = "1.15.0" [profile.release] lto = true opt-level = 3 codegen-units = 1 [profile.profiling] inherits = "release" debug = true [profile.ci] inherits = "release" opt-level = 2 lto = false [patch.crates-io] # It's diffcult to resovle different version with the same crate used in AppFlowy Frontend and the Client-API crate. # So using patch to workaround this issue. collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-importer = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e59260e524f33104b0ddcd6bb8f6218cad0f7e18" } [features] history = [] # Some AI test features are not available for self-hosted AppFlowy Cloud. Therefore, AI testing is disabled by default. ai-test-enabled = ["client-api-test/ai-test-enabled"] # Enable Debugging for Tokio Runtime with Tokio Console # Reference: https://github.com/tokio-rs/console # Steps to Enable and Use Tokio Console: # 1. Run your application with debugging enabled: # RUST_BACKTRACE=1 RUST_LOG=trace cargo run --package xtask # 2. Install the Tokio Console CLI (if not already installed): # cargo install --locked tokio-console # 3. Open a new terminal and start the Tokio Console: # tokio-console tokio-runtime-profile = ["console-subscriber", "tokio/tracing"] sync-v2 = ["client-api-test/v2"] # Enable CORS support for development environments (required when using dev.env configuration) # This allows frontend applications to access the backend during local development use_actix_cors = ["actix-cors"] ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 # Using cargo-chef to manage Rust build cache effectively FROM lukemathwalker/cargo-chef:latest-rust-1.86 as chef WORKDIR /app RUN apt update && apt install lld clang -y FROM chef as planner COPY . . # Compute a lock-like file for our project RUN cargo chef prepare --recipe-path recipe.json FROM chef as builder # Update package lists and install protobuf-compiler along with other build dependencies RUN apt update && apt install -y protobuf-compiler lld clang # Specify a default value for FEATURES; it could be an empty string if no features are enabled by default ARG FEATURES="" ARG PROFILE="release" COPY --from=planner /app/recipe.json recipe.json ENV CARGO_BUILD_JOBS=4 ENV CARGO_NET_GIT_FETCH_WITH_CLI=true ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse # Reduce memory usage during compilation RUN echo "Building appflowy cloud with profile: ${PROFILE}" RUN if [ "$PROFILE" = "release" ]; then \ cargo chef cook --release --recipe-path recipe.json; \ else \ cargo chef cook --recipe-path recipe.json; \ fi COPY . . ENV SQLX_OFFLINE true # Build the project RUN echo "Building with profile: ${PROFILE}, features: ${FEATURES}, " RUN if [ "$PROFILE" = "release" ]; then \ cargo build --release --features "${FEATURES}" --bin appflowy_cloud; \ else \ cargo build --features "${FEATURES}" --bin appflowy_cloud; \ fi FROM debian:bookworm-slim AS runtime WORKDIR /app RUN apt-get update -y \ && apt-get install -y --no-install-recommends openssl ca-certificates curl \ && update-ca-certificates \ # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # Copy the binary from the appropriate target directory ARG PROFILE="release" RUN echo "Building with profile: ${PROFILE}" RUN if [ "$PROFILE" = "release" ]; then \ echo "Using release binary"; \ else \ echo "Using debug binary"; \ fi COPY --from=builder /app/target/$PROFILE/appflowy_cloud /usr/local/bin/appflowy_cloud ENV APP_ENVIRONMENT production ENV RUST_BACKTRACE 1 ARG APPFLOWY_APPLICATION_PORT ARG PORT ENV PORT=${APPFLOWY_APPLICATION_PORT:-${PORT:-8000}} EXPOSE $PORT CMD ["appflowy_cloud"] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: Makefile ================================================ ROOT = "./script" SEMVER_VERSION=$(shell grep version Cargo.toml | awk -F"\"" '{print $$2}' | head -n 1) .PHONY: run test run: ${ROOT}/run_local_server.sh test: ${ROOT}/run_local_test.sh ================================================ FILE: README.md ================================================

License: AGPL

WebsiteTwitter

⚡ The AppFlowy Cloud written with Rust 🦀

# AppFlowy Cloud AppFlowy Cloud is adopting an open-core model to ensure the project's long-term sustainability. AppFlowy offers two deployment options: - AppFlowy Managed Cloud: AWS-hosted instances fully deployed and managed by the AppFlowy team - AppFlowy Self-hosted Cloud: Configurable services you can deploy on your own infrastructure The codebase behind these two setups is a closed-source fork of this open-source codebase: https://github.com/AppFlowy-IO/AppFlowy-Cloud, combined with our proprietary code. The commercial fork is distributed solely under our commercial license ([link](https://github.com/AppFlowy-IO/AppFlowy-SelfHost-Commercial/blob/main/SELF_HOST_LICENSE_AGREEMENT.md)). **AppFlowy Self-hosted Cloud is designed for teams and enterprises that want data control and modular, configurable components tailored to their own infrastructure.** It comes with a Free tier suitable for **experienced IT professionals to test out our self-hosted solution** and allows seamless upgrades to higher tiers. The Free tier offers: - One user seat (per instance) - AppFlowy Web App (your hosted appflowy.com/app) - Up to 3 guest editors who can be added to your selected AppFlowy pages and collaborate with you in real time - Publish pages - Unlimited workspaces You can find the pricing details by visiting [this page](https://appflowy.com/docs/Self-hosted-Plans-and-Pricing), or your self-hosted admin panel, or our official website ([link](https://appflowy.com/pricing)). Open source vs Open core: As AppFlwy Cloud is adopting an open-core model, AppFlowy Web and AppFlowy Flutter will remain open source. As part of the transition, we are consolidating and reorganizing our codebases in private repositories. Recently, we merged all AppFlowy Web–related development from our private repository back into the public one. You can check the [commit](https://github.com/AppFlowy-IO/AppFlowy-Web/commits/main/) for reference. We will also merge Flutter code back into [this](https://github.com/AppFlowy-IO/AppFlowy) public repository at a later stage. AppFlowy Cloud will continue to follow the open-core model for business reasons. You're free to use https://github.com/AppFlowy-IO/AppFlowy-Cloud governed by its license. We also have a few other open source repos, such as - [AppFlowy Website](https://github.com/AppFlowy-IO/AppFlowy-Website) for developers who want to build their own website following our navigation structure - [appflowy-editor](https://github.com/AppFlowy-IO/appflowy-editor) for Flutter developers to build their own apps - [appflowy-board](https://github.com/AppFlowy-IO/appflowy-board) for Flutter developers to build their own apps ## Table of Contents - [🚀 Deployment](#deployment) ## 🚀Deployment - See [deployment guide](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production) ================================================ FILE: SELF_HOST_LICENSE_AGREEMENT.md ================================================ APPFLOWY PTE. LTD. SOFTWARE LICENSE AGREEMENT IMPORTANT: PLEASE READ THIS SOFTWARE LICENSE AGREEMENT ("LICENSE AGREEMENT") CAREFULLY BEFORE USING THE APPFLOWY SELF-HOSTED COMMERCIAL SOFTWARE ("SOFTWARE"). BY USING THE SOFTWARE, YOU (EITHER AN INDIVIDUAL OR A SINGLE ENTITY) AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE AGREEMENT. IF YOU DO NOT AGREE TO THE TERMS OF THIS LICENSE AGREEMENT, DO NOT USE THE SOFTWARE. 1. License Grant: Subject to the terms and conditions of this License Agreement, APPFLOWY PTE. LTD. ("Licensor") grants you a non-exclusive, non-transferable license to use the Software for your internal business purposes. This license is granted on a per-server (per-machine) basis. 2 License Restrictions: a) You shall not copy, modify, distribute, sell, lease, sublicense, or transfer the Software or any portion thereof. b) You shall not reverse engineer, decompile, or disassemble the Software, except to the extent expressly permitted by applicable law. c) You shall not remove or alter any proprietary notices or labels on the Software. 3. License Key: a) APPFLOWY PTE. LTD. will provide you with a unique license key ("License Key") to activate the Software on a specific server (machine). b) You agree that the License Key provided to you by APPFLOWY PTE. LTD. will be used exclusively on the designated server (machine) and will not be shared, transferred, or used on any other server (machine) without explicit written permission from APPFLOWY PTE. LTD. c) Reselling license and custom client are not allowed. 4. Term and Billing: a) The license term for the Software shall be one (1) year, starting from the date of purchase. b) You agree to pay the annual license fee as specified by Licensor. Failure to make timely payments may result in suspension or termination of the license. 5. Maintenance and Support: a) Support is offered via email. b) During the license term, Licensor may provide maintenance and support services for the Software as separately agreed upon or specified in a separate support agreement. c) Licensor reserves the right to modify or enhance the support services at its sole discretion. 6. Intellectual Property: a) You acknowledge that the Software and any accompanying documentation are protected by intellectual property laws and treaties. b) You agree not to remove or alter any copyright, trademark, or other proprietary rights notices contained in or on the Software. 7. Disclaimer of Warranty: THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. 8. Limitation of Liability: IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING OUT OF OR RELATED TO THIS LICENSE AGREEMENT OR THE USE OF THE SOFTWARE, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 9. Termination: This License Agreement is effective until terminated. Licensor may terminate this License Agreement immediately upon notice to you if you breach any of the terms and conditions. Upon termination, you must cease all use of the Software and destroy all copies in your possession. 10. Governing Law: This License Agreement shall be governed by and construed in accordance with the laws of Singapore. Any legal action or proceeding arising under this License Agreement shall be brought exclusively in the courts of Singapore. 11. Entire Agreement: This License Agreement shall be governed by and construed in accordance with the laws of Singapore. Any legal action or proceeding arising under this License Agreement shall be brought exclusively in the courts of Singapore. 12. No Refund Policy: All fees paid under this License Agreement are non-cancellable and non-refundable, except to the extent required by applicable law (e.g., if the Software is not delivered, is materially defective or not as described, or a mandatory statutory cooling‑off right applies). If you are an EU/UK consumer, you may have a 14‑day statutory right of withdrawal for digital content. By requesting immediate delivery of the Software and/or License Key and ticking the checkbox at checkout (or prior to key reveal), you expressly consent to immediate performance and acknowledge that you lose that withdrawal right once delivery occurs. By using the Software, you acknowledge that you have read and understood this License Agreement and agree to be bound by its terms and conditions. ================================================ FILE: admin_frontend/Cargo.toml ================================================ [package] name = "admin_frontend" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local dependencies gotrue = { path = "../libs/gotrue" } gotrue-entity = { path = "../libs/gotrue-entity" } database-entity = { path = "../libs/database-entity" } shared-entity = { path = "../libs/shared-entity" } anyhow.workspace = true axum = { version = "0.7", features = ["json"] } tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] } askama = "0.12" axum-extra = { version = "0.9", features = ["cookie"] } serde.workspace = true serde_json.workspace = true redis = { version = "0.25.2", features = [ "aio", "tokio-comp", "connection-manager", ] } uuid = { workspace = true, features = ["v4"] } dotenvy = "0.15" reqwest.workspace = true tower-service = "0.3" tower-http = { version = "0.5", features = ["fs"] } tower = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } jwt = "0.16" human_bytes = "0.4.3" rand = "0.8.5" sha2 = "0.10.8" base64 = "0.22.1" urlencoding = "2.1.3" ================================================ FILE: admin_frontend/Dockerfile ================================================ # syntax=docker/dockerfile:1 # User should build from parent directory FROM lukemathwalker/cargo-chef:latest-rust-1.86 as chef WORKDIR /app FROM chef as builder ARG PROFILE="release" COPY . . WORKDIR /app/admin_frontend RUN echo "Building admin_frontend with profile: ${PROFILE}" RUN if [ "$PROFILE" = "release" ]; then \ cargo build --release --bin admin_frontend; \ else \ cargo build --bin admin_frontend; \ fi FROM debian AS runtime WORKDIR /app RUN apt-get update -y \ && apt-get install -y --no-install-recommends libc6 libssl-dev \ # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # Copy the binary from the appropriate target directory ARG PROFILE="release" RUN echo "Using admin_frontend with profile: ${PROFILE}" RUN if [ "$PROFILE" = "release" ]; then \ echo "Using release admin_frontend binary"; \ else \ echo "Using debug admin_frontend binary"; \ fi COPY --from=builder /app/target/$PROFILE/admin_frontend /usr/local/bin/admin_frontend COPY --from=builder /app/admin_frontend/assets /app/assets ENV RUST_BACKTRACE 1 ENV RUST_LOG info ARG ADMIN_FRONTEND_PORT ARG PORT ENV PORT=${ADMIN_FRONTEND_PORT:-${PORT:-3000}} EXPOSE $PORT CMD ["admin_frontend"] ================================================ FILE: admin_frontend/README.md ================================================ # Admin Frontend ## Partial Local Environment - Go to source root folder of `AppFlowy-Cloud` - Start running locally dependency servers: `docker compose --file docker-compose-dev.yml up -d` - Start SQLX migrations `cargo sqlx database create && cargo sqlx migrate run && cargo sqlx prepare --workspace` - Start AppFlowy-Cloud Server `cargo run` - Go back to `AppFlowy-Cloud/admin_frontend` directory - Run `cargo watch -x run -w .`, this watch for source changes, rebuild and rerun the app. ## Full Local Integration Environment - Start the whole stack: `docker compose up -d` - Go to [web server](localhost) - After editing source files, do `docker compose up -d --no-deps --build admin_frontend` - You might need to add `--force-recreate` flag for non build changes to take effect ================================================ FILE: admin_frontend/assets/README.md ================================================ # Assets ================================================ FILE: admin_frontend/assets/apple/logo.html ================================================ ================================================ FILE: admin_frontend/assets/base.css ================================================ :root { --af-background: #1a202c; --line-divider: #384967; --af-purple: #9327ff; --af-dark-purple: #8427e0; --af-red: #fb006d; --af-dark-red: #e3006d; --af-cyan: #00c8ff; --af-dark-cyan: #00b5ff; --af-yellow: #ffce00; --af-dark-yellow: #ffbd00; --af-darker-yellow: #f7931e; } body { font-family: sans-serif; background-color: var(--af-background); color: #e2e9f2; } .button { border: none; border-radius: 4px; margin: 4px; padding: 4px 8px; font-size: 16px; cursor: pointer; } .table { border-collapse: collapse; /* Collapse borders so there's no double borders */ } .table th, .table td { padding: 8px; } .yellow-table th { border: 1px solid var(--af-dark-yellow); background-color: var(--af-yellow); } .yellow-table td { border: 1px solid var(--af-dark-yellow); } .red-table th { border: 1px solid var(--af-dark-red); background-color: var(--af-red); } .red-table td { border: 1px solid var(--af-dark-red); } .cyan-table th { border: 1px solid var(--af-dark-cyan); background-color: var(--af-cyan); } .cyan-table td { border: 1px solid var(--af-dark-cyan); } .purple-table th { border: 1px solid var(--af-dark-purple); background-color: var(--af-purple); } .purple-table td { border: 1px solid var(--af-dark-purple); } .purple:hover { background-color: var(--af-dark-purple); } .purple { background-color: var(--af-dark-purple); } .purple:hover { background-color: var(--af-purple); } .red { background-color: var(--af-dark-red); } .red:hover { background-color: var(--af-red); } .cyan { background-color: var(--af-dark-cyan); } .cyan:hover { background-color: var(--af-cyan); } .yellow { background-color: var(--af-darker-yellow); } .yellow:hover { background-color: var(--af-yellow); } .input { box-sizing: border-box; border-radius: 4px; border: 1px solid var(--line-divider); margin: 4px 0; padding: 4px 8px; font-size: 16px; color: inherit; background-color: inherit; outline: none; } .input:hover { border-color: var(--af-dark-cyan); } .input:focus { border-color: var(--af-cyan); } .loading-button { filter: brightness(0.7); } .oauth-item-inner { border: 1px solid; border-color: var(--line-divider); width: 100%; margin: 4px; border-radius: 8px; } .oauth-item-inner:hover { border-color: var(--af-dark-cyan); } .divider { border: none; border-top: 1px solid var(--line-divider); } ================================================ FILE: admin_frontend/assets/discord/README.md ================================================ # Discord - Assets are derived from: https://discord.com/branding ================================================ FILE: admin_frontend/assets/discord/logo.html ================================================ ================================================ FILE: admin_frontend/assets/github/README.md ================================================ # Github - Assets derived from: https://github.com/logos ================================================ FILE: admin_frontend/assets/github/logo.html ================================================ ================================================ FILE: admin_frontend/assets/google/README.md ================================================ # Google OAuth Sign Logo Assets in this directory are generated from: https://developers.google.com/identity/branding-guidelines ================================================ FILE: admin_frontend/assets/google/logo.css ================================================ .gsi-material-button { -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; -webkit-appearance: none; background-color: #f2f2f2; background-image: none; border: none; -webkit-border-radius: 20px; border-radius: 20px; -webkit-box-sizing: border-box; box-sizing: border-box; color: #1f1f1f; cursor: pointer; font-family: "Roboto", arial, sans-serif; font-size: 14px; height: 40px; letter-spacing: 0.25px; outline: none; overflow: hidden; padding: 0; position: relative; text-align: center; -webkit-transition: background-color 0.218s, border-color 0.218s, box-shadow 0.218s; transition: background-color 0.218s, border-color 0.218s, box-shadow 0.218s; vertical-align: middle; white-space: nowrap; width: 40px; max-width: 400px; min-width: min-content; } .gsi-material-button .gsi-material-button-icon { height: 20px; margin-right: 12px; min-width: 20px; width: 20px; margin: 0; padding: 10px; } .gsi-material-button .gsi-material-button-content-wrapper { -webkit-align-items: center; align-items: center; display: flex; -webkit-flex-direction: row; flex-direction: row; -webkit-flex-wrap: nowrap; flex-wrap: nowrap; height: 100%; justify-content: space-between; position: relative; width: 100%; } .gsi-material-button .gsi-material-button-contents { -webkit-flex-grow: 1; flex-grow: 1; font-family: "Roboto", arial, sans-serif; font-weight: 500; overflow: hidden; text-overflow: ellipsis; vertical-align: top; } .gsi-material-button .gsi-material-button-state { -webkit-transition: opacity 0.218s; transition: opacity 0.218s; bottom: 0; left: 0; opacity: 0; position: absolute; right: 0; top: 0; } .gsi-material-button:disabled { cursor: default; background-color: #ffffff61; } .gsi-material-button:disabled .gsi-material-button-state { background-color: #1f1f1f1f; } .gsi-material-button:disabled .gsi-material-button-contents { opacity: 38%; } .gsi-material-button:disabled .gsi-material-button-icon { opacity: 38%; } .gsi-material-button:not(:disabled):active .gsi-material-button-state, .gsi-material-button:not(:disabled):focus .gsi-material-button-state { background-color: #001d35; opacity: 12%; } .gsi-material-button:not(:disabled):hover { -webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); } .gsi-material-button:not(:disabled):hover .gsi-material-button-state { background-color: #001d35; opacity: 8%; } ================================================ FILE: admin_frontend/assets/google/logo.html ================================================ ================================================ FILE: admin_frontend/assets/home.css ================================================ #home { display: flex; } #sidebar-content { width: 100%; } ================================================ FILE: admin_frontend/assets/login.css ================================================ #login-parent { display: flex; align-items: center; justify-content: center; width: 100%; flex-direction: column; } #login-splash { display: flex; align-items: center; justify-content: center; flex-direction: column; } #login-signin { min-width: 256px; max-width: 512px; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } #oauth-container { display: flex; flex-direction: row; align-items: center; justify-content: center; width: 100%; flex-wrap: wrap; } .oauth-icon { margin: 12px; width: 48px; } ================================================ FILE: admin_frontend/assets/logo.html ================================================ ================================================ FILE: admin_frontend/assets/message.css ================================================ @keyframes slideIn { from { top: -20%; } to { top: 1%; } } .message.slideIn { animation: slideIn 0.2s forwards; } .message { border: none; border-radius: 4px; padding: 8px 16px; display: none; position: fixed; left: 50%; transform: translateX(-50%); color: #fff; z-index: 1; } ================================================ FILE: admin_frontend/assets/minio/logo.html ================================================ ================================================ FILE: admin_frontend/assets/navigate.css ================================================ .nav-item { border-bottom: 2px solid; } .svg-container { margin: 16px; display: flex; /* Using Flexbox for alignment */ height: 64px; align-items: center; /* Vertically center the SVG */ justify-content: flex-start; /* Align the SVG to the left */ } .svg-container:hover { filter: drop-shadow(0px 0px 8px); } .svg-container svg { height: 100%; width: auto; display: block; } ================================================ FILE: admin_frontend/assets/postgres/logo.html ================================================ ================================================ FILE: admin_frontend/assets/sidebar.css ================================================ #sidebar { display: flex; flex-direction: column; width: 128px; height: 100vh; } .sidebar-item { padding: 16px 16px; /* Padding to give button-like space */ cursor: pointer; /* Changing the cursor on hover to indicate clickability */ } .sidebar-item:hover { text-shadow: 0 0 8px; } ================================================ FILE: admin_frontend/assets/top_menu_bar.css ================================================ #top-menu-bar { display: flex; justify-content: space-between; align-items: center; } #top-menu-bar-left { display: flex; align-items: center; justify-content: space-between; } #top-menu-bar-right { display: flex; text-align: right; } ================================================ FILE: admin_frontend/dev.env ================================================ GOTRUE_URL=http://localhost:9999 REDIS_URL=redis://localhost:6379 RUST_LOG=trace ================================================ FILE: admin_frontend/src/askama_entities.rs ================================================ use database_entity::dto::AFWorkspace; use crate::ext::entities::WorkspaceMember; pub struct WorkspaceWithMembers { pub workspace: AFWorkspace, pub members: Vec, } ================================================ FILE: admin_frontend/src/config.rs ================================================ use tracing::warn; #[derive(Debug, Clone)] pub struct Config { pub host: String, pub port: u16, pub redis_url: String, pub gotrue_url: String, pub appflowy_cloud_url: String, pub oauth: OAuthConfig, pub path_prefix: String, } #[derive(Debug, Clone)] pub struct OAuthConfig { pub client_id: String, pub client_secret: Option, pub allowable_redirect_uris: Vec, } impl Config { pub fn from_env() -> Result { let cfg = Config { host: get_or_default("ADMIN_FRONTEND_HOST", "0.0.0.0"), port: get_or_default("ADMIN_FRONTEND_PORT", "3000") .parse() .map_err(|e| anyhow::anyhow!("failed to parse ADMIN_FRONTEND_PORT as u16, err: {}", e))?, redis_url: get_or_default("ADMIN_FRONTEND_REDIS_URL", "redis://localhost:6379"), gotrue_url: get_or_default("ADMIN_FRONTEND_GOTRUE_URL", "http://localhost:9999"), appflowy_cloud_url: get_or_default( "ADMIN_FRONTEND_APPFLOWY_CLOUD_URL", "http://localhost:8000", ), oauth: OAuthConfig { client_id: get_or_default("ADMIN_FRONTEND_OAUTH_CLIENT_ID", "appflowy_cloud"), client_secret: get_optional("ADMIN_FRONTEND_OAUTH_CLIENT_SECRET"), allowable_redirect_uris: get_or_default( "ADMIN_FRONTEND_OAUTH_ALLOWABLE_REDIRECT_URIS", "http://localhost:3000", ) .split(',') .map(|s| s.to_string()) .collect(), }, path_prefix: get_or_default("ADMIN_FRONTEND_PATH_PREFIX", ""), }; Ok(cfg) } } fn get_or_default(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|e| { warn!( "failed to get env var: {}, err: {}, using default: {}", key, e, default ); default.to_string() }) } fn get_optional(key: &str) -> Option { let s = match std::env::var(key) { Ok(s) => s, Err(err) => { warn!("failed to get env var: {}, err: {}", key, err); return None; }, }; if s.is_empty() { warn!("env var: {} is empty", key); None } else { Some(s) } } ================================================ FILE: admin_frontend/src/error.rs ================================================ use std::borrow::Cow; use axum::{ http::{status, StatusCode}, response::{IntoResponse, Redirect}, }; use crate::ext; pub struct WebApiError<'a> { pub status_code: status::StatusCode, pub payload: Cow<'a, str>, } impl<'a> WebApiError<'a> { pub fn new(status_code: status::StatusCode, payload: S) -> Self where S: Into>, { WebApiError { status_code, payload: payload.into(), } } } impl IntoResponse for WebApiError<'_> { fn into_response(self) -> axum::response::Response { let status = self.status_code; let payload = self.payload.into_owned(); // Converts Cow into String (status, payload).into_response() } } impl From for WebApiError<'_> { fn from(v: gotrue_entity::error::GoTrueError) -> Self { WebApiError::new(status::StatusCode::UNAUTHORIZED, v.to_string()) } } impl From for WebApiError<'_> { fn from(v: redis::RedisError) -> Self { WebApiError::new(status::StatusCode::INTERNAL_SERVER_ERROR, v.to_string()) } } pub enum WebAppError { Askama(askama::Error), LoginRedirectRequired(String), ExtApi(ext::error::Error), Redis(redis::RedisError), BadRequest(String), } impl IntoResponse for WebAppError { fn into_response(self) -> axum::response::Response { match self { WebAppError::Askama(e) => { tracing::error!("askama error: {:?}", e); status::StatusCode::INTERNAL_SERVER_ERROR.into_response() }, WebAppError::LoginRedirectRequired(base_path) => { Redirect::to(&format!("{}/login", base_path)).into_response() }, WebAppError::ExtApi(e) => e.into_response(), WebAppError::Redis(e) => { tracing::error!("redis error: {:?}", e); status::StatusCode::INTERNAL_SERVER_ERROR.into_response() }, WebAppError::BadRequest(e) => { tracing::error!("bad request: {:?}", e); status::StatusCode::BAD_REQUEST.into_response() }, } } } impl From for WebAppError { fn from(v: redis::RedisError) -> Self { WebAppError::Redis(v) } } impl From for WebAppError { fn from(v: askama::Error) -> Self { WebAppError::Askama(v) } } impl From for WebAppError { fn from(v: ext::error::Error) -> Self { WebAppError::ExtApi(v) } } impl From for WebApiError<'_> { fn from(v: ext::error::Error) -> Self { match v { ext::error::Error::NotOk(code, payload) => { WebApiError::new(StatusCode::from_u16(code).unwrap(), payload) }, err => WebApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", err)), } } } ================================================ FILE: admin_frontend/src/ext/api.rs ================================================ use database_entity::dto::{AFRole, AFWorkspace, AFWorkspaceInvitation}; use shared_entity::dto::{auth_dto::SignInTokenResponse, workspace_dto::WorkspaceMemberInvitation}; use super::{ check_response, entities::{ UserProfile, UserUsageLimit, WorkspaceBlobUsage, WorkspaceDocUsage, WorkspaceMember, WorkspaceUsageLimits, }, error::Error, from_json_response, }; pub async fn get_user_owned_workspaces( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let user_profile = get_user_profile(access_token, appflowy_cloud_base_url).await?; let owned_workspaces = get_user_workspaces(access_token, appflowy_cloud_base_url) .await? .into_iter() .filter(|w| w.owner_uid == user_profile.uid) .collect::>(); Ok(owned_workspaces) } pub async fn get_user_workspaces( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let http_client = reqwest::Client::new(); let resp = http_client .get(format!("{}/api/workspace", appflowy_cloud_base_url)) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn get_user_workspace_limit( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result { let http_client = reqwest::Client::new(); let resp = http_client .get(format!("{}/api/user/limit", appflowy_cloud_base_url)) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn get_user_workspace_usages( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let user_workspaces = get_user_owned_workspaces(access_token, appflowy_cloud_base_url).await?; let mut workspace_usages: Vec = Vec::with_capacity(user_workspaces.len()); for user_workspace in user_workspaces { let workspace_id = user_workspace.workspace_id.to_string(); let members = get_workspace_members(&workspace_id, access_token, appflowy_cloud_base_url).await?; let total_blob_size = get_user_workspace_blob_usage(&workspace_id, access_token, appflowy_cloud_base_url) .await .map(|u| human_bytes::human_bytes(u.consumed_capacity as f64)) .unwrap_or_else(|err| { tracing::error!("Error getting user workspace blob usage: {:?}", err); "0".to_owned() }); let total_doc_size = { get_user_workspace_doc_usage(&workspace_id, access_token, appflowy_cloud_base_url) .await .map(|u| human_bytes::human_bytes(u.total_document_size as f64)) .unwrap_or_else(|err| { tracing::error!("Error getting user workspace doc usage: {:?}", err); "0".to_owned() }) }; workspace_usages.push(WorkspaceUsageLimits { name: user_workspace.workspace_name, member_count: members.len(), total_doc_size, total_blob_size, }); } Ok(workspace_usages) } pub async fn get_workspace_members( workspace_id: &str, access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let http_client = reqwest::Client::new(); let resp = http_client .get(format!( "{}/api/workspace/{}/member", appflowy_cloud_base_url, workspace_id )) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn get_pending_workspace_invitations( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let http_client = reqwest::Client::new(); let resp = http_client .get(format!( "{}/api/workspace/invite?status=Pending", appflowy_cloud_base_url )) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn get_accepted_workspace_invitations( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result, Error> { let http_client = reqwest::Client::new(); let resp = http_client .get(format!( "{}/api/workspace/invite?status=Accepted", appflowy_cloud_base_url )) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } async fn get_user_workspace_blob_usage( workspace_id: &str, access_token: &str, appflowy_cloud_gateway_base_url: &str, ) -> Result { let http_client = reqwest::Client::new(); let resp = http_client .get(format!( "{}/api/file_storage/{}/usage", appflowy_cloud_gateway_base_url, workspace_id )) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } async fn get_user_workspace_doc_usage( workspace_id: &str, access_token: &str, appflowy_cloud_base_url: &str, ) -> Result { let http_client = reqwest::Client::new(); let url = format!( "{}/api/workspace/{}/usage", appflowy_cloud_base_url, workspace_id ); let resp = http_client .get(url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn get_user_profile( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result { let http_client = reqwest::Client::new(); let url = format!("{}/api/user/profile", appflowy_cloud_base_url); let resp = http_client .get(url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; from_json_response(resp).await } pub async fn invite_user_to_workspace( access_token: &str, workspace_id: &str, user_email: &str, appflowy_cloud_base_url: &str, ) -> Result<(), Error> { let invi = vec![WorkspaceMemberInvitation { email: user_email.to_string(), role: AFRole::Member, skip_email_send: true, ..Default::default() }]; let http_client = reqwest::Client::new(); let url = format!( "{}/api/workspace/{}/invite", appflowy_cloud_base_url, workspace_id ); let resp = http_client .post(url) .header("Authorization", format!("Bearer {}", access_token)) .json(&invi) .send() .await?; check_response(resp).await } pub async fn leave_workspace( access_token: &str, workspace_id: &str, appflowy_cloud_base_url: &str, ) -> Result<(), Error> { let http_client = reqwest::Client::new(); let url = format!( "{}/api/workspace/{}/leave", appflowy_cloud_base_url, workspace_id ); let resp = http_client .post(url) .header("Authorization", format!("Bearer {}", access_token)) .json(&()) .send() .await?; check_response(resp).await } pub async fn accept_workspace_invitation( access_token: &str, invite_id: &str, appflowy_cloud_base_url: &str, ) -> Result<(), Error> { let http_client = reqwest::Client::new(); let url = format!( "{}/api/workspace/accept-invite/{}", appflowy_cloud_base_url, invite_id ); let resp = http_client .post(url) .header("Authorization", format!("Bearer {}", access_token)) .json(&()) .send() .await?; check_response(resp).await } pub async fn verify_token_cloud( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result<(), Error> { let http_client = reqwest::Client::new(); let url = format!( "{}/api/user/verify/{}", appflowy_cloud_base_url, access_token ); let resp = http_client .get(url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; let _: SignInTokenResponse = from_json_response(resp).await?; Ok(()) } pub async fn delete_current_user( access_token: &str, appflowy_cloud_base_url: &str, ) -> Result<(), Error> { let http_client = reqwest::Client::new(); let url = format!("{}/api/user", appflowy_cloud_base_url); let resp = http_client .delete(url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await?; check_response(resp).await?; Ok(()) } ================================================ FILE: admin_frontend/src/ext/entities.rs ================================================ use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct JsonResponse { pub code: u16, pub data: T, } #[derive(Deserialize)] pub struct UserUsageLimit { pub workspace_count: i64, } #[derive(Serialize)] pub struct WorkspaceUsageLimits { pub name: String, pub member_count: usize, pub total_doc_size: String, pub total_blob_size: String, } #[derive(Deserialize)] #[allow(dead_code)] pub struct WorkspaceMember { pub name: String, pub email: String, pub role: String, } #[derive(Deserialize, Serialize)] pub struct WorkspaceBlobUsage { pub consumed_capacity: u64, } #[derive(Deserialize)] pub struct WorkspaceDocUsage { pub total_document_size: i64, } #[derive(Deserialize)] #[allow(dead_code)] pub struct UserProfile { pub uid: i64, pub uuid: Uuid, pub email: Option, pub password: Option, pub name: Option, pub metadata: Option, pub encryption_sign: Option, pub latest_workspace_id: Uuid, pub updated_at: i64, } ================================================ FILE: admin_frontend/src/ext/error.rs ================================================ use axum::response::{IntoResponse, Response}; use shared_entity::response::AppResponseError; #[derive(Debug)] #[allow(dead_code)] pub enum Error { NotOk(u16, String), // HTTP status code, payload Reqwest(reqwest::Error), AppFlowyCloud(AppResponseError), Unhandled(String), } impl From for Error { fn from(err: reqwest::Error) -> Self { Error::Reqwest(err) } } impl IntoResponse for Error { fn into_response(self) -> Response { match self { Error::NotOk(status_code, payload) => Response::builder() .status(status_code) .body(payload.into()) .unwrap(), err => Response::builder() .status(500) .body(format!("Unhandled error: {:?}", err).into()) .unwrap(), } } } ================================================ FILE: admin_frontend/src/ext/mod.rs ================================================ use shared_entity::response::{AppResponseError, ErrorCode}; use crate::ext::entities::JsonResponse; pub mod api; pub mod entities; pub mod error; async fn from_json_response(resp: reqwest::Response) -> Result where T: serde::de::DeserializeOwned, { if !resp.status().is_success() { let status = resp.status(); let payload = resp.text().await?; return Err(error::Error::NotOk(status.as_u16(), payload)); } let payload = resp.text().await?; match serde_json::from_str::>(&payload) { Ok(data) => Ok(data.data), Err(_) => match serde_json::from_str::(&payload) { Ok(af_cloud_err) => Err(error::Error::AppFlowyCloud(af_cloud_err)), Err(err) => Err(error::Error::Unhandled(format!( "Failed to parse JSON response: {:?}, Payload: {}", err, payload ))), }, } } async fn check_response(resp: reqwest::Response) -> Result<(), error::Error> { let status = resp.status(); let payload = resp.text().await?; if !status.is_success() { return Err(error::Error::NotOk(status.as_u16(), payload)); } if let Ok(cloud_err) = serde_json::from_str::(&payload) { if cloud_err.code == ErrorCode::Ok { return Ok(()); } else { return Err(error::Error::AppFlowyCloud(cloud_err)); } }; Ok(()) } ================================================ FILE: admin_frontend/src/lib.rs ================================================ pub mod config; pub mod models; pub mod session; ================================================ FILE: admin_frontend/src/main.rs ================================================ mod askama_entities; mod config; mod error; mod ext; mod models; mod response; mod session; mod templates; mod web_api; mod web_app; use axum::{response::Redirect, routing::get, Router}; use models::AppState; use tokio::net::TcpListener; use tower_http::services::ServeDir; use tracing::info; use crate::config::Config; #[tokio::main] async fn main() { // load from .env dotenvy::dotenv().ok(); // set up tracing tracing_subscriber::fmt() .json() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_line_number(true) .init(); let config = Config::from_env().unwrap(); info!("config loaded: {:?}", &config); let gotrue_client = gotrue::api::Client::new(reqwest::Client::new(), &config.gotrue_url); gotrue_client .health() .await .expect("gotrue health check failed"); info!("Gotrue client initialized."); let redis_client = redis::Client::open(config.redis_url.clone()) .expect("failed to create redis client") .get_connection_manager() .await .expect("failed to get redis connection manager"); info!("Redis client initialized."); let session_store = session::SessionStorage::new(redis_client); let address = format!("{}:{}", config.host, config.port); let path_prefix = config.path_prefix.clone(); let state = AppState { appflowy_cloud_url: config.appflowy_cloud_url.clone(), gotrue_client, session_store, config, }; let web_app_router = web_app::router(state.clone()).with_state(state.clone()); let web_api_router = web_api::router().with_state(state.clone()); let favicon_redirect_url = state.prepend_with_path_prefix("/assets/favicon.ico"); let base_path_redirect_url = state.prepend_with_path_prefix("/web"); let base_app = Router::new() .route( "/favicon.ico", get(|| async { let favicon_redirect_url = favicon_redirect_url; Redirect::permanent(&favicon_redirect_url) }), ) .route( "/", get(|| async { let base_path_redirect_url = base_path_redirect_url; Redirect::permanent(&base_path_redirect_url) }), ) .nest_service("/web", web_app_router) .nest_service("/web-api", web_api_router) .nest_service("/assets", ServeDir::new("assets")); let app = if path_prefix.is_empty() { base_app } else { Router::new().nest(&path_prefix, base_app) }; let listener = TcpListener::bind(address) .await .expect("failed to bind to port"); info!("listening on: {:?}", listener); axum::serve(listener, app) .await .expect("failed to run server"); } ================================================ FILE: admin_frontend/src/models.rs ================================================ use serde::{Deserialize, Serialize}; use crate::{config::Config, session}; #[derive(Clone)] pub struct AppState { pub appflowy_cloud_url: String, pub gotrue_client: gotrue::api::Client, pub session_store: session::SessionStorage, pub config: Config, } impl AppState { pub fn prepend_with_path_prefix(&self, path: &str) -> String { format!("{}{}", self.config.path_prefix, path) } } #[derive(Serialize, Deserialize)] pub struct WebApiLoginRequest { pub email: String, pub password: String, pub redirect_to: Option, } #[derive(Deserialize)] pub struct WebApiPutUserRequest { pub password: String, } #[derive(Deserialize)] pub struct WebApiChangePasswordRequest { pub new_password: String, pub confirm_password: String, } #[derive(Deserialize)] pub struct WebApiAdminCreateUserRequest { pub email: String, pub password: String, pub require_email_verification: bool, } #[derive(Deserialize)] pub struct WebApiInviteUserRequest { pub email: String, } #[derive(Deserialize)] pub struct WebApiCreateSSOProviderRequest { #[serde(rename = "type")] pub type_: String, pub metadata_url: String, } #[derive(Deserialize)] pub struct WebAppOAuthLoginRequest { // Use for Login pub refresh_token: Option, // Use actions (with params) after login pub action: Option, // Workspace Invitation pub workspace_invitation_id: Option, pub workspace_name: Option, pub workspace_icon: Option, pub user_name: Option, pub user_icon: Option, pub workspace_member_count: Option, // Redirect pub redirect_to: Option, // Errors pub error: Option, pub error_code: Option, pub error_description: Option, } #[derive(Deserialize)] #[serde(rename_all = "snake_case")] pub enum OAuthLoginAction { AcceptWorkspaceInvite, } #[derive(Debug, Serialize, Deserialize)] pub struct OAuthRedirect { pub client_id: String, pub state: String, pub redirect_uri: String, pub response_type: String, // pub scope: Option, pub code_challenge: Option, pub code_challenge_method: Option, } #[derive(Debug, Serialize, Deserialize, Default)] pub struct OAuthRedirectToken { pub code: String, pub client_id: Option, pub client_secret: Option, pub grant_type: String, pub redirect_uri: Option, pub code_verifier: Option, } #[derive(Debug, Deserialize)] pub struct LoginParams { pub redirect_to: Option, } ================================================ FILE: admin_frontend/src/response.rs ================================================ use std::borrow::Cow; use axum::{response::IntoResponse, Json}; #[derive(serde::Serialize)] pub struct WebApiResponse where T: serde::Serialize, { pub code: i16, pub message: Cow<'static, str>, pub data: T, } impl WebApiResponse where T: serde::Serialize, { pub fn new(message: Cow<'static, str>, data: T) -> Self { Self { code: 0, message, data, } } } impl IntoResponse for WebApiResponse where T: serde::Serialize, { fn into_response(self) -> axum::response::Response { Json(self).into_response() } } impl From for WebApiResponse where T: serde::Serialize, { fn from(data: T) -> Self { Self::new("success".into(), data) } } impl WebApiResponse<()> { pub fn from_str(message: Cow<'static, str>) -> Self { Self::new(message, ()) } } ================================================ FILE: admin_frontend/src/session.rs ================================================ use crate::models::AppState; use std::time::{SystemTime, UNIX_EPOCH}; use axum::{ async_trait, extract::{FromRequestParts, OriginalUri}, http::request::Parts, response::{IntoResponse, Redirect}, }; use axum_extra::extract::{cookie::Cookie, CookieJar}; use gotrue::grant::{Grant, RefreshTokenGrant}; use gotrue_entity::dto::GotrueTokenResponse; use jwt::{Claims, Header}; use redis::{aio::ConnectionManager, AsyncCommands, FromRedisValue, ToRedisArgs}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; static SESSION_EXPIRATION: usize = 60 * 60 * 24; // 1 day #[derive(Clone)] pub struct SessionStorage { redis_client: ConnectionManager, } fn session_id_key(session_id: &str) -> String { format!("web::session::{}", session_id) } fn code_session_key(code: &str) -> String { format!("web::session::code::{}", code) } impl SessionStorage { pub fn new(redis_client: ConnectionManager) -> Self { Self { redis_client } } pub async fn get_user_session( &self, session_id: &str, ) -> Result, redis::RedisError> { let key = session_id_key(session_id); let user_session_optional: UserSessionOptional = self.redis_client.clone().get(&key).await?; Ok(user_session_optional.0) } pub async fn get_code_session( &self, code: &str, ) -> Result, redis::RedisError> { let key = code_session_key(code); let code_session_optional: CodeSessionOptional = self.redis_client.clone().get(&key).await?; Ok(code_session_optional.0) } pub async fn put_user_session(&self, user_session: &UserSession) -> redis::RedisResult<()> { let key = session_id_key(&user_session.session_id); self .redis_client .clone() .set_options( key, user_session, redis::SetOptions::default().with_expiration(redis::SetExpiry::EX(SESSION_EXPIRATION)), ) .await } pub async fn del_user_session(&self, session_id: &str) -> redis::RedisResult<()> { let key = session_id_key(session_id); let res = self.redis_client.clone().del::<_, i64>(key).await?; tracing::info!("del user session: {} res: {}", session_id, res); Ok(()) } pub async fn put_code_session( &self, code: &str, code_session: &CodeSession, ) -> redis::RedisResult<()> { let key = code_session_key(code); self .redis_client .clone() .set_options( key, code_session, redis::SetOptions::default().with_expiration(redis::SetExpiry::EX(60 * 5)), // code is valid for 5 minutes ) .await } } #[derive(Debug, Serialize, Deserialize)] pub struct CodeSession { pub session_id: String, pub code_challenge: Option, pub code_challenge_method: Option, } struct CodeSessionOptional(Option); impl ToRedisArgs for CodeSession { fn write_redis_args(&self, out: &mut W) where W: ?Sized + redis::RedisWrite, { let s = serde_json::to_string(self).unwrap(); out.write_arg(s.as_bytes()); } } impl FromRedisValue for CodeSessionOptional { fn from_redis_value(v: &redis::Value) -> redis::RedisResult { let bytes = expect_redis_value_data(v)?; match bytes { Some(bytes) => { let session = expect_redis_json_bytes(bytes)?; Ok(CodeSessionOptional(Some(session))) }, None => Ok(CodeSessionOptional(None)), } } } #[derive(Debug, Serialize, Deserialize)] pub struct UserSession { pub session_id: String, pub token: GotrueTokenResponse, } struct UserSessionOptional(Option); #[async_trait] impl FromRequestParts for UserSession { type Rejection = axum::response::Response; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let jar = match CookieJar::from_request_parts(parts, state).await { Ok(jar) => jar, Err(err) => { tracing::error!("failed to get cookie jar, error: {}", err); let redirect_url = state.prepend_with_path_prefix("/web/login"); return Err(Redirect::to(&redirect_url).into_response()); }, }; if let Some(session) = get_session_from_store(&jar, &state.session_store, &state.gotrue_client).await { return Ok(session); } let original_url = parts .extensions .get::() .map(|uri| urlencoding::encode(&uri.to_string()).to_string()); match original_url { Some(url) => { let redirect_url = state.prepend_with_path_prefix(&format!("/web/login-v2?redirect_to={}", url)); Err(Redirect::to(&redirect_url).into_response()) }, None => { let redirect_url = state.prepend_with_path_prefix("/web/login"); Err(Redirect::to(&redirect_url).into_response()) }, } } } async fn get_session_from_store( cookie_jar: &CookieJar, session_store: &SessionStorage, gotrue_client: &gotrue::api::Client, ) -> Option { let session_id = match cookie_jar.get("session_id") { Some(cookie) => cookie.value(), None => { tracing::info!("no session_id cookie found"); return None; }, }; let mut session = session_store .get_user_session(session_id) .await .unwrap_or_else(|err| { tracing::error!("failed to get session from store: {}", err); None })?; if has_expired(session.token.access_token.as_str()) { // Get new pair of access token and refresh token let refresh_token = session.token.refresh_token; let new_token = match gotrue_client .clone() .token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token })) .await { Ok(token) => token, Err(err) => { tracing::warn!("failed to refresh token: {}", err); return None; }, }; session.token.access_token = new_token.access_token; session.token.refresh_token = new_token.refresh_token; // Update session in redis session_store .put_user_session(&session) .await .unwrap_or_else(|err| tracing::error!("failed to update session: {}", err)); } Some(session) } fn has_expired(access_token: &str) -> bool { match get_session_expiration(access_token) { Some(expiration_seconds) => { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); now > expiration_seconds }, None => false, } } fn get_session_expiration(access_token: &str) -> Option { // no need to verify, let the appflowy cloud server do it // in that way, frontend server does not need to know the secret match jwt::Token::::parse_unverified(access_token) { Ok(unverified) => unverified.claims().registered.expiration, Err(e) => { tracing::error!("failed to parse unverified token: {}", e); None }, } } impl ToRedisArgs for UserSession { fn write_redis_args(&self, out: &mut W) where W: ?Sized + redis::RedisWrite, { let s = serde_json::to_string(self).unwrap(); out.write_arg(s.as_bytes()); } } impl FromRedisValue for UserSessionOptional { fn from_redis_value(v: &redis::Value) -> redis::RedisResult { let bytes = expect_redis_value_data(v)?; match bytes { Some(bytes) => { let session = expect_redis_json_bytes(bytes)?; Ok(UserSessionOptional(Some(session))) }, None => Ok(UserSessionOptional(None)), } } } fn expect_redis_json_bytes(v: &[u8]) -> redis::RedisResult where T: DeserializeOwned, { let res: Result = serde_json::from_slice(v); match res { Ok(v) => Ok(v), Err(e) => Err(redis::RedisError::from(( redis::ErrorKind::TypeError, "redis data json deserialization failed!", e.to_string(), ))), } } fn expect_redis_value_data(v: &redis::Value) -> redis::RedisResult> { match v { redis::Value::Data(ref bytes) => Ok(Some(bytes)), redis::Value::Nil => Ok(None), x => Err(redis::RedisError::from(( redis::ErrorKind::TypeError, "unexpected value from redis", format!("redis value is not data: {:?}", x), ))), } } pub fn new_session_cookie(id: uuid::Uuid) -> Cookie<'static> { let mut cookie = Cookie::new("session_id", id.to_string()); cookie.set_path("/"); cookie } ================================================ FILE: admin_frontend/src/templates.rs ================================================ use askama::Template; use database_entity::dto::{AFWorkspace, AFWorkspaceInvitation}; use gotrue_entity::{dto::User, sso::SSOProvider}; use crate::{askama_entities::WorkspaceWithMembers, ext::entities::WorkspaceUsageLimits}; #[derive(Template)] #[template(path = "pages/redirect.html")] pub struct Redirect { pub redirect_url: String, } #[derive(Template)] #[template(path = "pages/open_appflowy_or_download.html")] pub struct OpenAppFlowyOrDownload {} #[derive(Template)] #[template(path = "pages/login_callback.html")] pub struct LoginCallback {} #[derive(Template)] #[template(path = "pages/payment_success_redirect.html")] pub struct PaymentSuccessRedirect {} #[derive(Template)] #[template(path = "components/user_usage.html")] pub struct UserUsage { pub workspace_count: usize, pub workspace_limit: String, } // ./../templates/components/workspace_usage.html #[derive(Template)] #[template(path = "components/workspace_usage.html")] pub struct WorkspaceUsageList { pub workspace_usages: Vec, } #[derive(Template)] #[template(path = "components/admin_sso_detail.html")] pub struct SsoDetail { pub sso_provider: SSOProvider, pub mapping_json: String, } #[derive(Template)] #[template(path = "components/admin_sso_create.html")] pub struct SsoCreate; #[derive(Template)] #[template(path = "components/admin_sso_list.html")] pub struct SsoList { pub sso_providers: Vec, } #[derive(Template)] #[template(path = "components/change_password.html")] pub struct ChangePassword; #[derive(Template)] #[template(path = "pages/login.html")] pub struct Login<'a> { pub path_prefix: &'a str, pub oauth_providers: &'a [&'a str], pub redirect_to: Option<&'a str>, pub oauth_redirect_to: &'a str, } #[derive(Template)] #[template(path = "pages/login_v2.html")] pub struct LoginV2<'a> { pub oauth_providers: &'a [&'a str], pub redirect_to: Option<&'a str>, pub oauth_redirect_to: &'a str, pub path_prefix: &'a str, } #[derive(Template)] #[template(path = "pages/home.html")] pub struct Home<'a> { pub user: &'a User, pub is_admin: bool, pub path_prefix: &'a str, } #[derive(Template)] #[template(path = "components/create_user.html")] pub struct CreateUser; #[derive(Template)] #[template(path = "components/invite.html")] pub struct Invite { pub shared_workspaces: Vec, pub owned_workspaces: Vec, pub pending_workspace_invitations: Vec, } #[derive(Template)] #[template(path = "components/shared_workspaces.html")] pub struct SharedWorkspaces { pub shared_workspaces: Vec, } #[derive(Template)] #[template(path = "components/admin_navigate.html")] pub struct AdminNavigate; #[derive(Template)] #[template(path = "components/navigate.html")] pub struct Navigate; #[derive(Template)] #[template(path = "pages/admin_home.html")] pub struct AdminHome<'a> { pub path_prefix: &'a str, pub user: &'a User, } #[derive(Template)] #[template(path = "components/admin_users.html")] pub struct AdminUsers<'a> { pub users: &'a [gotrue_entity::dto::User], } #[derive(Template)] #[template(path = "components/user_details.html")] pub struct UserDetails<'a> { pub user: &'a gotrue_entity::dto::User, } #[derive(Template)] #[template(path = "components/admin_user_details.html")] pub struct AdminUserDetails<'a> { pub user: &'a gotrue_entity::dto::User, } // Any filter defined in the module `filters` is accessible in your template. mod filters { pub fn default( input: &Option, default_val: &str, ) -> ::askama::Result { Ok( input .as_ref() .map(|i| i.to_string()) .unwrap_or_else(|| default_val.to_string()), ) } } ================================================ FILE: admin_frontend/src/web_api.rs ================================================ use crate::error::WebApiError; use crate::ext::api::{ accept_workspace_invitation, delete_current_user, invite_user_to_workspace, leave_workspace, verify_token_cloud, }; use crate::models::{AppState, WebApiLoginRequest}; use crate::models::{ LoginParams, OAuthRedirect, OAuthRedirectToken, WebApiAdminCreateUserRequest, WebApiChangePasswordRequest, WebApiCreateSSOProviderRequest, WebApiInviteUserRequest, WebApiPutUserRequest, }; use crate::response::WebApiResponse; use crate::session::{self, new_session_cookie, CodeSession, UserSession}; use axum::extract::{Path, Query}; use axum::http::{status, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Result}; use axum::routing::{delete, get}; use axum::Form; use axum::{extract::State, routing::post, Router}; use axum_extra::extract::cookie::Cookie; use axum_extra::extract::CookieJar; use base64::engine::Engine; use base64::prelude::BASE64_STANDARD_NO_PAD; use gotrue::params::{ AdminDeleteUserParams, AdminUserParams, CreateSSOProviderParams, GenerateLinkParams, MagicLinkParams, }; use gotrue_entity::dto::{GotrueTokenResponse, SignUpResponse, UpdateGotrueUserParams, User}; use rand::distributions::Alphanumeric; use rand::Rng; use sha2::Digest; use tracing::info; pub fn router() -> Router { Router::new() .route("/signin", post(sign_in_handler)) .route("/oauth-redirect", get(oauth_redirect_handler)) .route("/oauth-redirect/token", get(oauth_redirect_token_handler)) .route("/signup", post(sign_up_handler)) .route("/login-refresh/:refresh_token", post(login_refresh_handler)) .route("/logout", post(logout_handler)) // user .route("/change-password", post(change_password_handler)) .route("/oauth_login/:provider", post(post_oauth_login_handler)) .route("/invite", post(invite_handler)) .route("/workspace/:workspace_id/invite", post(workspace_invite_handler)) .route("/workspace/:workspace_id/leave", post(leave_workspace_handler)) .route("/invite/:invite_id/accept", post(invite_accept_handler)) .route("/open_app", post(open_app_handler)) .route("/delete-account", delete(delete_account_handler)) // admin .route("/admin/user", post(admin_add_user_handler)) .route( "/admin/user/:user_uuid", delete(admin_delete_user_handler).put(admin_update_user_handler), ) .route( "/admin/user/:email/generate-link", post(post_user_generate_link_handler), ) .route("/admin/sso", post(admin_create_sso_handler)) .route("/admin/sso/:provider_id", delete(admin_delete_sso_handler)) } async fn admin_delete_sso_handler( State(state): State, session: UserSession, Path(provider_id): Path, ) -> Result, WebApiError<'static>> { let _ = state .gotrue_client .admin_delete_sso_provider(&session.token.access_token, &provider_id) .await?; Ok(WebApiResponse::<()>::from_str("SSO Deleted".into())) } async fn admin_create_sso_handler( State(state): State, session: UserSession, Form(param): Form, ) -> Result, WebApiError<'static>> { let provider_params = CreateSSOProviderParams { type_: param.type_, metadata_url: param.metadata_url, ..Default::default() }; let _ = state .gotrue_client .admin_create_sso_providers(&session.token.access_token, &provider_params) .await?; Ok(WebApiResponse::<()>::from_str("SSO Added".into())) } /// Generates a URL to facilitate login redirection to the AppFlowy app from a web browser. /// /// This function creates a custom URL scheme that can be used in a web browser to open the /// AppFlowy app and automatically handle user login based on the provided `UserSession`. /// /// # Returns /// A `Result` containing `HeaderMap` for HTTP redirection if successful, or `WebApiError` in case of failure. /// /// # Example URL Format /// `appflowy-flutter://login-callback#access_token=...&expires_at=...&expires_in=...&refresh_token=...&token_type=...` /// /// The URL includes access token information and other relevant session details. /// /// # Usage /// The client application should implement handling for this URL format, typically through the /// `sign_in_with_url` method in the `client-api` crate. See [client_api::Client::sign_in_with_url] for more details. /// async fn open_app_handler( session: UserSession, ) -> Result> { let app_sign_in_url = format!( "appflowy-flutter://login-callback#access_token={}&expires_at={}&expires_in={}&refresh_token={}&token_type={}", session.token.access_token, session.token.expires_at, session.token.expires_in, session.token.refresh_token, session.token.token_type, ); Ok(htmx_redirect(&app_sign_in_url).into_response()) } /// Delete the user account and all associated data. async fn delete_account_handler( state: State, session: UserSession, ) -> Result> { delete_current_user(&session.token.access_token, &state.appflowy_cloud_url).await?; let redirect_url = state.prepend_with_path_prefix("/web/login"); Ok(Redirect::to(&redirect_url).into_response()) } // Invite another user, this will trigger email sending // to the target user async fn invite_handler( State(state): State, Form(param): Form, ) -> Result, WebApiError<'static>> { let magic_link_redirect = if state.config.path_prefix.is_empty() { "/".to_owned() } else { state.config.path_prefix.clone() }; state .gotrue_client .magic_link( &MagicLinkParams { email: param.email, ..Default::default() }, Some(magic_link_redirect), ) .await?; Ok(WebApiResponse::<()>::from_str("Invitation sent".into())) } async fn workspace_invite_handler( State(state): State, session: UserSession, Path(workspace_id): Path, Form(param): Form, ) -> Result, WebApiError<'static>> { invite_user_to_workspace( &session.token.access_token, &workspace_id, ¶m.email, &state.appflowy_cloud_url, ) .await?; Ok(WebApiResponse::<()>::from_str("Invitation sent".into())) } async fn leave_workspace_handler( State(state): State, session: UserSession, Path(workspace_id): Path, ) -> Result, WebApiError<'static>> { leave_workspace( &session.token.access_token, &workspace_id, &state.appflowy_cloud_url, ) .await?; Ok(WebApiResponse::<()>::from_str("Left workspace".into())) } async fn invite_accept_handler( State(state): State, session: UserSession, Path(invite_id): Path, ) -> Result> { accept_workspace_invitation( &session.token.access_token, &invite_id, &state.appflowy_cloud_url, ) .await?; Ok(htmx_trigger("workspaceInvitationAccepted")) } async fn change_password_handler( State(state): State, session: UserSession, Form(param): Form, ) -> Result, WebApiError<'static>> { if param.new_password != param.confirm_password { return Err(WebApiError::new( status::StatusCode::BAD_REQUEST, "passwords do not match", )); } let _user = state .gotrue_client .update_user( &session.token.access_token, &UpdateGotrueUserParams { password: Some(param.new_password), ..Default::default() }, ) .await?; Ok(WebApiResponse::<()>::from_str("Password changed".into())) } async fn post_oauth_login_handler( header_map: HeaderMap, Path(provider): Path, ) -> Result, WebApiError<'static>> { let base_url = get_base_url(&header_map); let redirect_uri = format!("{}/web/oauth_login_redirect", base_url); let oauth_url = format!( "{}/authorize?provider={}&redirect_uri={}", base_url, &provider, redirect_uri ); Ok(oauth_url.into()) } async fn admin_update_user_handler( State(state): State, session: UserSession, Path(user_uuid): Path, Form(param): Form, ) -> Result, WebApiError<'static>> { let res = state .gotrue_client .admin_update_user( &session.token.access_token, &user_uuid, &AdminUserParams { password: Some(param.password.to_owned()), email_confirm: true, ..Default::default() }, ) .await?; Ok(res.into()) } async fn post_user_generate_link_handler( State(state): State, session: UserSession, Path(email): Path, ) -> Result> { let res = state .gotrue_client .admin_generate_link( &session.token.access_token, &GenerateLinkParams { email, ..Default::default() }, ) .await?; Ok(res.action_link) } async fn admin_delete_user_handler( State(state): State, session: UserSession, Path(user_uuid): Path, ) -> Result, WebApiError<'static>> { state .gotrue_client .admin_delete_user( &session.token.access_token, &user_uuid, &AdminDeleteUserParams { should_soft_delete: false, }, ) .await?; Ok(().into()) } async fn admin_add_user_handler( State(state): State, session: UserSession, Form(param): Form, ) -> Result, WebApiError<'static>> { let add_user_params = AdminUserParams { email: param.email, password: Some(param.password), email_confirm: !param.require_email_verification, ..Default::default() }; let _user = state .gotrue_client .admin_add_user(&session.token.access_token, &add_user_params) .await?; Ok(WebApiResponse::<()>::from_str("User created".into())) } async fn login_refresh_handler( State(state): State, jar: CookieJar, Path(refresh_token): Path, Query(login): Query, ) -> Result> { let token = state .gotrue_client .token(&gotrue::grant::Grant::RefreshToken( gotrue::grant::RefreshTokenGrant { refresh_token }, )) .await?; // Do another round of refresh_token to consume and invalidate the old one let token = state .gotrue_client .token(&gotrue::grant::Grant::RefreshToken( gotrue::grant::RefreshTokenGrant { refresh_token: token.refresh_token, }, )) .await?; session_login(State(state), token, jar, login.redirect_to.as_deref()).await } // login and set the cookie // sign up if not exist async fn sign_in_handler( State(state): State, jar: CookieJar, Form(param): Form, ) -> Result> { let WebApiLoginRequest { email, password, redirect_to, } = param; if password.is_empty() { let res = send_magic_link(State(state), &email).await?; return Ok(res.into_response()); } // Attempt to sign in with email and password let token = state .gotrue_client .token(&gotrue::grant::Grant::Password( gotrue::grant::PasswordGrant { email: email.to_owned(), password: password.to_owned(), }, )) .await?; session_login(State(state), token, jar, redirect_to.as_deref()).await } async fn oauth_redirect_handler( State(state): State, session: UserSession, Query(oauth_redirect): Query, ) -> Result> { { // OAuthRedirect verification if oauth_redirect.client_id != state.config.oauth.client_id { return Err(WebApiError::new( StatusCode::BAD_REQUEST, "invalid client_id", )); } if oauth_redirect.response_type != "code" { return Err(WebApiError::new( StatusCode::BAD_REQUEST, "invalid response_type, only 'code' is support", )); } { // Check if the redirect_uri is in the allowable list let mut found = false; for allowable_uri in &state.config.oauth.allowable_redirect_uris { if oauth_redirect.redirect_uri == *allowable_uri { found = true; break; } } if !found { return Err(WebApiError::new( StatusCode::BAD_REQUEST, format!( "invalid redirect_uri: {}, allowable_uris: {}", oauth_redirect.redirect_uri, state.config.oauth.allowable_redirect_uris.join(", ") ), )); } } } let code = gen_rand_alpha_num(32); state .session_store .put_code_session( &code, &CodeSession { session_id: session.session_id.clone(), code_challenge: oauth_redirect.code_challenge, code_challenge_method: oauth_redirect.code_challenge_method, }, ) .await?; let url = format!( "{}?code={}&state={}", oauth_redirect.redirect_uri, code, oauth_redirect.state, ); let resp = Redirect::to(&url).into_response(); Ok(resp) } async fn oauth_redirect_token_handler( State(state): State, Query(token_req): Query, ) -> Result> { // Check client secret (if exists) if let Some(server_client_secret) = state.config.oauth.client_secret { match token_req.client_secret { Some(given_client_secret) => { if server_client_secret != given_client_secret { return Err(WebApiError::new( StatusCode::BAD_REQUEST, "invalid client_secret", )); } }, _ => { return Err(WebApiError::new( StatusCode::BAD_REQUEST, "expecting client_secret", )); }, } }; let code_session = state .session_store .get_code_session(&token_req.code) .await? .ok_or_else(|| WebApiError::new(StatusCode::BAD_REQUEST, "invalid code"))?; if let Some(code_challenge) = code_session.code_challenge { match code_session.code_challenge_method.as_deref() { Some("S256") => { let verifier = token_req.code_verifier.ok_or_else(|| { WebApiError::new(status::StatusCode::BAD_REQUEST, "missing code_verifier") })?; // get code challenge based64 decoded let code_challenge = BASE64_STANDARD_NO_PAD .decode(code_challenge) .map_err(|err| { WebApiError::new( status::StatusCode::BAD_REQUEST, format!("failed to base64 decode code challege: {}", err), ) })?; // hash the verifier and check against the original code challenge let mut hasher = sha2::Sha256::new(); hasher.update(verifier.as_bytes()); let verifier_hashed = hasher.finalize().to_vec(); if verifier_hashed != code_challenge { return Err(WebApiError::new( status::StatusCode::BAD_REQUEST, "invalid code_verifier", )); } }, _ => { return Err(WebApiError::new( status::StatusCode::BAD_REQUEST, "invalid code_challenge_method, only support S256", )); }, } } let user_session = state .session_store .get_user_session(&code_session.session_id) .await? .ok_or_else(|| WebApiError::new(StatusCode::BAD_REQUEST, "invalid session"))?; let resp = axum::Json::from(user_session.token); Ok(resp.into_response()) } async fn sign_up_handler( State(state): State, jar: CookieJar, Form(param): Form, ) -> Result> { let WebApiLoginRequest { email, password, redirect_to, } = param; if password.is_empty() { let res = send_magic_link(State(state), &email).await?; return Ok(res.into_response()); } let sign_up_res = state .gotrue_client .sign_up(&email, &password, Some("/")) .await?; match sign_up_res { // when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in SignUpResponse::Authenticated(token) => { session_login(State(state), token, jar, redirect_to.as_deref()).await }, SignUpResponse::NotAuthenticated(user) => { info!("user signed up and not authenticated: {:?}", user); Ok(WebApiResponse::<()>::from_str("Email Verification Sent".into()).into_response()) }, } } async fn logout_handler( State(state): State, jar: CookieJar, ) -> Result> { let session_id = jar .get("session_id") .ok_or(WebApiError::new( status::StatusCode::BAD_REQUEST, "no session_id cookie", ))? .value(); state.session_store.del_user_session(session_id).await?; let htmx_redirect_url = format!("{}/web/login", state.config.path_prefix); Ok( ( jar.remove(Cookie::from("session_id")), htmx_redirect(&htmx_redirect_url), ) .into_response(), ) } fn htmx_trigger(trigger: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert("HX-Trigger", trigger.parse().unwrap()); h } async fn session_login( State(state): State, token: GotrueTokenResponse, jar: CookieJar, redirect_to: Option<&str>, ) -> Result> { verify_token_cloud( token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; let new_session_id = uuid::Uuid::new_v4(); let new_session = session::UserSession { session_id: new_session_id.to_string(), token, }; state.session_store.put_user_session(&new_session).await?; let decoded_redirect_to = redirect_to.and_then(|s| match urlencoding::decode(s) { Ok(r) => Some(r), Err(err) => { tracing::error!("failed to decode redirect_to: {}", err); None }, }); let default_htmx_redirect_url = format!("{}/web/home", state.config.path_prefix); Ok( ( jar.add(new_session_cookie(new_session_id)), htmx_redirect( decoded_redirect_to .as_deref() .unwrap_or(&default_htmx_redirect_url), ), ) .into_response(), ) } async fn send_magic_link( State(state): State, email: &str, ) -> Result, WebApiError<'static>> { state .gotrue_client .magic_link( &MagicLinkParams { email: email.to_owned(), ..Default::default() }, Some(format!("{}/web/login-callback", state.config.path_prefix)), ) .await?; Ok(WebApiResponse::<()>::from_str("Magic Link Sent".into())) } fn htmx_redirect(url: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert("Location", url.parse().unwrap()); h.insert("HX-Redirect", url.parse().unwrap()); h } fn get_base_url(header_map: &HeaderMap) -> String { let scheme = get_header_value_or_default(header_map, "x-scheme", "http"); let host = get_header_value_or_default(header_map, "host", "localhost"); format!("{}://{}", scheme, host) } fn get_header_value_or_default<'a>( header_map: &'a HeaderMap, header_name: &str, default: &'a str, ) -> &'a str { match header_map.get(header_name) { Some(v) => match v.to_str() { Ok(v) => v, Err(e) => { tracing::error!("failed to get header value {}: {}, {:?}", header_name, e, v); default }, }, None => default, } } fn gen_rand_alpha_num(n: usize) -> String { let random_string: String = rand::thread_rng() .sample_iter(&Alphanumeric) .take(n) .map(char::from) .collect(); random_string } ================================================ FILE: admin_frontend/src/web_app.rs ================================================ use crate::askama_entities::WorkspaceWithMembers; use crate::error::WebAppError; use crate::ext::api::{ accept_workspace_invitation, get_accepted_workspace_invitations, get_pending_workspace_invitations, get_user_owned_workspaces, get_user_profile, get_user_workspace_limit, get_user_workspace_usages, get_user_workspaces, get_workspace_members, verify_token_cloud, }; use crate::models::{LoginParams, OAuthLoginAction, WebAppOAuthLoginRequest}; use crate::session::{self, new_session_cookie, UserSession}; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Redirect, Result}; use axum::{response::Html, routing::get, Router}; use axum_extra::extract::CookieJar; use gotrue_entity::dto::User; use crate::{templates, AppState}; static DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX: &str = "/web/login-callback"; pub fn router(state: AppState) -> Router { Router::new() .nest_service("/", page_router().with_state(state.clone())) .nest_service("/components", component_router().with_state(state)) } fn page_router() -> Router { Router::new() .route("/", get(home_handler)) .route("/login", get(login_handler)) .route("/login-v2", get(login_v2_handler)) .route("/login-callback", get(login_callback_handler)) .route("/payment-success", get(payment_success_handler)) .route("/login-callback-query", get(login_callback_query_handler)) .route( "/open-appflowy-or-download", get(open_appflowy_or_download_handler), ) .route("/home", get(home_handler)) .route("/admin/home", get(admin_home_handler)) } fn component_router() -> Router { Router::new() // User actions .route("/user/navigate", get(user_navigate_handler)) .route("/user/user", get(user_user_handler)) .route("/user/change-password", get(user_change_password_handler)) .route("/user/invite", get(user_invite_handler)) .route("/user/shared-workspaces", get(shared_workspaces_handler)) .route("/user/user-usage", get(user_usage_handler)) .route("/user/workspace-usage", get(workspace_usage_handler)) // Admin actions .route("/admin/navigate", get(admin_navigate_handler)) .route("/admin/users", get(admin_users_handler)) .route("/admin/users/:user_id", get(admin_user_details_handler)) .route("/admin/users/create", get(admin_users_create_handler)) // SSO .route("/admin/sso", get(admin_sso_handler)) .route("/admin/sso/create", get(admin_sso_create_handler)) .route("/admin/sso/:sso_provider_id", get(admin_sso_detail_handler)) } async fn open_appflowy_or_download_handler() -> Result, WebAppError> { render_template(templates::OpenAppFlowyOrDownload {}) } async fn login_callback_handler() -> Result, WebAppError> { render_template(templates::LoginCallback {}) } async fn payment_success_handler() -> Result, WebAppError> { render_template(templates::PaymentSuccessRedirect {}) } async fn login_callback_query_handler( State(state): State, session: Option, Query(query): Query, mut jar: CookieJar, ) -> Result { let refresh_token = { match query.refresh_token { Some(refresh_token) => refresh_token, None => match session { Some(session) => session.token.refresh_token, None => match query.error { Some(err) => { tracing::error!( "OAuth login error: {:?}, code: {:?}, description: {:?}", err, query.error_code, query.error_description ); let redirect_url = format!( "https://appflowy.io/invitation/expired?workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}", query.workspace_name.unwrap_or_default(), query.workspace_icon.unwrap_or_default(), query.user_name.unwrap_or_default(), query.user_icon.unwrap_or_default(), query.workspace_member_count.unwrap_or_default()); let expired_html = render_template(templates::Redirect { redirect_url })?; return Ok(expired_html.into_response()); }, None => { return Err(WebAppError::BadRequest( "refresh_token not found".to_string(), )); }, }, }, } }; let token = state .gotrue_client .token(&gotrue::grant::Grant::RefreshToken( gotrue::grant::RefreshTokenGrant { refresh_token }, )) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; verify_token_cloud( token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; let new_session_id = uuid::Uuid::new_v4(); let new_session = session::UserSession { session_id: new_session_id.to_string(), token, }; state.session_store.put_user_session(&new_session).await?; jar = jar.add(new_session_cookie(new_session_id)); match query.action { Some(action) => match action { OAuthLoginAction::AcceptWorkspaceInvite => { let invite_id = query .workspace_invitation_id .ok_or(WebAppError::BadRequest( "workspace_invitation_id not found".to_string(), ))?; { // If user has already accepted the invitation, redirect to open or download AppFlowy let accepted_invitations = get_accepted_workspace_invitations( &new_session.token.access_token, &state.appflowy_cloud_url, ) .await?; let found = accepted_invitations .iter() .find(|w| w.invite_id.to_string() == invite_id); if found.is_some() { let open_or_dl_html = render_template(templates::OpenAppFlowyOrDownload {})?; return Ok((jar, open_or_dl_html).into_response()); } } if let Err(err) = accept_workspace_invitation( &new_session.token.access_token, &invite_id, &state.appflowy_cloud_url, ) .await { tracing::error!("accepting workspace invitation: {:?}", err); let redirect_url = format!( "https://appflowy.io/invitation/expired?workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}", query.workspace_name.unwrap_or_default(), query.workspace_icon.unwrap_or_default(), query.user_name.unwrap_or_default(), query.user_icon.unwrap_or_default(), query.workspace_member_count.unwrap_or_default()); let redirect_html = render_template(templates::Redirect { redirect_url })?; return Ok(redirect_html.into_response()); }; let open_or_dl_html = render_template(templates::OpenAppFlowyOrDownload {})?; Ok((jar, open_or_dl_html).into_response()) }, }, None => match query.redirect_to { Some(redirect_url) => match urlencoding::decode(&redirect_url).map(String::from) { Ok(redirect_url) => { let redirect_html = render_template(templates::Redirect { redirect_url })?; Ok((jar, redirect_html).into_response()) }, Err(err) => { tracing::error!("Error decoding redirect_url: {:?}", err); home_handler(State(state), Some(new_session), jar).await }, }, None => home_handler(State(state), Some(new_session), jar).await, }, } } async fn admin_sso_detail_handler( State(state): State, session: UserSession, Path(sso_provider_id): Path, ) -> Result, WebAppError> { let sso_provider = state .gotrue_client .admin_get_sso_provider(&session.token.access_token, &sso_provider_id) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; let mapping_json = serde_json::to_string_pretty(&sso_provider.saml.attribute_mapping).unwrap_or("".to_owned()); render_template(templates::SsoDetail { sso_provider, mapping_json, }) } async fn admin_sso_create_handler() -> Result, WebAppError> { render_template(templates::SsoCreate) } async fn admin_sso_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let sso_providers = state .gotrue_client .admin_list_sso_providers(&session.token.access_token) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))? .items .unwrap_or_default(); render_template(templates::SsoList { sso_providers }) } async fn user_navigate_handler() -> Result, WebAppError> { render_template(templates::Navigate) } async fn admin_navigate_handler() -> Result, WebAppError> { render_template(templates::AdminNavigate) } async fn shared_workspaces_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let user_workspaces = get_user_workspaces(&session.token.access_token, &state.appflowy_cloud_url).await?; let profile = get_user_profile( session.token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; let shared_workspaces = user_workspaces .into_iter() .filter(|workspace| workspace.owner_uid != profile.uid) .collect::>(); render_template(templates::SharedWorkspaces { shared_workspaces }) } async fn user_invite_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let user_workspaces = get_user_workspaces(&session.token.access_token, &state.appflowy_cloud_url).await?; let profile = get_user_profile( session.token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; let mut shared_workspaces = Vec::new(); let mut owned_workspaces = Vec::with_capacity(user_workspaces.len()); for workspace in user_workspaces { if workspace.owner_uid == profile.uid { let members = get_workspace_members( workspace.workspace_id.to_string().as_str(), session.token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; owned_workspaces.push(WorkspaceWithMembers { workspace, members }); } else { shared_workspaces.push(workspace); } } let pending_workspace_invitations = get_pending_workspace_invitations( session.token.access_token.as_str(), state.appflowy_cloud_url.as_str(), ) .await?; render_template(templates::Invite { shared_workspaces, owned_workspaces, pending_workspace_invitations, }) } async fn user_usage_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let workspace_count = get_user_owned_workspaces(&session.token.access_token, &state.appflowy_cloud_url) .await .map(|workspaces| workspaces.len()) .unwrap_or_else(|err| { tracing::error!("Error getting user workspace count: {:?}", err); 0 }); let workspace_limit = get_user_workspace_limit(&session.token.access_token, &state.appflowy_cloud_url) .await .map(|limit| limit.workspace_count.to_string()) .unwrap_or_else(|err| { tracing::warn!("unable to get user workspace limit: {:?}", err); "N/A".to_owned() }); render_template(templates::UserUsage { workspace_count, workspace_limit, }) } async fn workspace_usage_handler( State(app_state): State, session: UserSession, ) -> Result, WebAppError> { let workspace_usages = get_user_workspace_usages(&session.token.access_token, &app_state.appflowy_cloud_url).await?; render_template(templates::WorkspaceUsageList { workspace_usages }) } async fn admin_users_create_handler() -> Result, WebAppError> { render_template(templates::CreateUser) } async fn user_user_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let user = state .gotrue_client .user_info(&session.token.access_token) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; render_template(templates::UserDetails { user: &user }) } async fn login_handler( State(state): State, Query(login): Query, ) -> Result, WebAppError> { let redirect_to = login .redirect_to .as_ref() .map(|r| urlencoding::encode(r).to_string()); let oauth_redirect_to = login.redirect_to.as_ref().map(|r| { urlencoding::encode(&format!( "{}/web/login-callback?redirect_to={}", state.config.path_prefix, urlencoding::encode(r) )) .to_string() }); let external = state .gotrue_client .settings() .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))? .external; let oauth_providers = external.oauth_providers(); let default_oauth_redirect_to = format!( "{}{}", state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX ); render_template(templates::Login { path_prefix: &state.config.path_prefix, oauth_providers: &oauth_providers, redirect_to: redirect_to.as_deref(), oauth_redirect_to: oauth_redirect_to .as_deref() .unwrap_or(&default_oauth_redirect_to), }) } async fn login_v2_handler( State(state): State, Query(login): Query, ) -> Result, WebAppError> { let redirect_to = login .redirect_to .as_ref() .map(|r| urlencoding::encode(r).to_string()); let oauth_redirect_to = login.redirect_to.as_ref().map(|r| { urlencoding::encode(&format!( "{}/web/login-callback?redirect_to={}", state.config.path_prefix, urlencoding::encode(r) )) .to_string() }); let default_oauth_redirect_to = format!( "{}{}", state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX ); render_template(templates::LoginV2 { oauth_providers: &["Google", "Apple", "Github", "Discord"], redirect_to: redirect_to.as_deref(), oauth_redirect_to: oauth_redirect_to .as_deref() .unwrap_or(&default_oauth_redirect_to), path_prefix: &state.config.path_prefix, }) } async fn user_change_password_handler() -> Result, WebAppError> { render_template(templates::ChangePassword) } pub async fn home_handler( State(state): State, session: Option, jar: CookieJar, ) -> Result { let redirect_url = state.prepend_with_path_prefix("/web/login"); let session = match session { Some(session) => session, None => return Ok(Redirect::to(&redirect_url).into_response()), }; let user = state .gotrue_client .user_info(&session.token.access_token) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; let home_html_str = render_template(templates::Home { user: &user, is_admin: is_admin(&user), path_prefix: &state.config.path_prefix, })?; Ok((jar, home_html_str).into_response()) } async fn admin_home_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let user = state .gotrue_client .user_info(&session.token.access_token) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; render_template(templates::AdminHome { user: &user, path_prefix: &state.config.path_prefix, }) } async fn admin_users_handler( State(state): State, session: UserSession, ) -> Result, WebAppError> { let users = state .gotrue_client .admin_list_user(&session.token.access_token, None) .await .map_or_else( |err| { tracing::error!("Error getting user list: {:?}", err); vec![] }, |r| r.users, ) .into_iter() .filter(|user| user.deleted_at.is_none()) .collect::>(); render_template(templates::AdminUsers { users: &users }) } async fn admin_user_details_handler( State(state): State, session: UserSession, Path(user_id): Path, ) -> Result, WebAppError> { let user = state .gotrue_client .admin_user_details(&session.token.access_token, &user_id) .await .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?; render_template(templates::AdminUserDetails { user: &user }) } fn render_template(x: T) -> Result, WebAppError> where T: Template, { let s = x.render()?; Ok(Html(s)) } fn is_admin(user: &User) -> bool { user.role == "supabase_admin" } ================================================ FILE: admin_frontend/templates/components/admin_navigate.html ================================================
================================================ FILE: admin_frontend/templates/components/admin_sidebar.html ================================================ ================================================ FILE: admin_frontend/templates/components/admin_sso_create.html ================================================

Please enter the following information to create new SSO

Email
Metadata Url
================================================ FILE: admin_frontend/templates/components/admin_sso_detail.html ================================================
ID {{ sso_provider.id|escape }}
Entity ID {{ sso_provider.saml.entity_id|escape }}
Domains
    {% for domain in sso_provider.domains %}
  • {{ domain|escape }}
  • {% endfor %}
Created At {{ sso_provider.created_at|escape }}
Updated At {{ sso_provider.updated_at|escape }}
Metadata XML {{ sso_provider.saml.metadata_xml|default("")|escape }}
Attribute Mapping {{ mapping_json|escape }}
================================================ FILE: admin_frontend/templates/components/admin_sso_list.html ================================================
{% for sso_provider in sso_providers %} {% endfor %}
Entity ID Created At Actions
{{ sso_provider.saml.entity_id|escape }} {{ sso_provider.created_at|escape }}
================================================ FILE: admin_frontend/templates/components/admin_top_menu_bar.html ================================================
{% include "../assets/logo.html" %}

  AppFlowy Cloud  

{{ user.email|escape }}
User
Logout
================================================ FILE: admin_frontend/templates/components/admin_user_details.html ================================================
{% include "user_details.html" %}
Set Password:
================================================ FILE: admin_frontend/templates/components/admin_users.html ================================================
{% for user in users %} {% endfor %}
Email Created At Actions
{{ user.email|escape }} {{ user.created_at|escape }}
================================================ FILE: admin_frontend/templates/components/appflowy_banner.html ================================================

AppFlowy Cloud

================================================ FILE: admin_frontend/templates/components/change_password.html ================================================

Password Change

New Password:
Confirm Password:
================================================ FILE: admin_frontend/templates/components/create_user.html ================================================

Please enter the following information to create a new user

Email:
Password:
Require Email Verification:
================================================ FILE: admin_frontend/templates/components/invite.html ================================================

Invite another user to AppFlowy

Email:

Workspaces shared with you

{% include "shared_workspaces.html" %}

Invite another user to your workspace

{% for owned_workspace in owned_workspaces %} {% endfor %}
Workspace Name Members Invite
{{ owned_workspace.workspace.workspace_name|escape }} {% for member in owned_workspace.members %} {{ member.email|escape }}
{% endfor %}

Invitation(s) from other user(s)

{% for pending_workspace_invitation in pending_workspace_invitations %} {% endfor %}
Workspace Name Inviter Action
{{ pending_workspace_invitation.workspace_name|default("")|escape }} {{ pending_workspace_invitation.inviter_email|default("")|escape }}
================================================ FILE: admin_frontend/templates/components/message.html ================================================
================================================ FILE: admin_frontend/templates/components/navigate.html ================================================
================================================ FILE: admin_frontend/templates/components/shared_workspaces.html ================================================ {% for shared_workspace in shared_workspaces %} {% endfor %}
Workspace Name Owner Name Action
{{ shared_workspace.workspace_name|escape }} {{ shared_workspace.owner_name|escape }}
================================================ FILE: admin_frontend/templates/components/sidebar.html ================================================ ================================================ FILE: admin_frontend/templates/components/top_menu_bar.html ================================================
{% include "../assets/logo.html" %}

  AppFlowy Cloud  

{{ user.email|escape }}
Delete Account
{% if is_admin %}
Admin
{% endif %}
Logout
================================================ FILE: admin_frontend/templates/components/user_details.html ================================================

Email: {{ user.email|escape }}

Role: {{ user.role|escape }}

Phone: {{ user.phone|escape }}

Email Confirmed At: {{ user.email_confirmed_at|default("-")|escape }}

Phone Confirmed At: {{ user.phone_confirmed_at|default("-")|escape }}

Last Sign In At: {{ user.last_sign_in_at|default("-")|escape }}

Created At: {{ user.created_at|escape }}

Updated At: {{ user.updated_at|escape }}

================================================ FILE: admin_frontend/templates/components/user_usage.html ================================================
Type Current Limit
Workspaces {{ workspace_count|escape }} {{ workspace_limit|escape }}
================================================ FILE: admin_frontend/templates/components/workspace_usage.html ================================================
{% for workspace_usage in workspace_usages %} {% endfor %} ================================================ FILE: admin_frontend/templates/layouts/base.html ================================================ {% block title %}{{ title|escape }}{% endblock %} {% block head %}{% endblock %} {% include "components/message.html" %}
{% block content %}{% endblock %}
================================================ FILE: admin_frontend/templates/pages/admin_home.html ================================================ {% extends "layouts/base.html" %} {% block title %} AppFlowy Cloud Admin {% endblock %} {% block head %} {% endblock %} {% block content %} {% include "components/admin_top_menu_bar.html" %}
{% include "components/admin_sidebar.html" %}
{% endblock %} ================================================ FILE: admin_frontend/templates/pages/home.html ================================================ {% extends "layouts/base.html" %} {% block title %} AppFlowy Cloud {% endblock %} {% block head %} {% endblock %} {% block content %} {% include "components/top_menu_bar.html" %}
{% include "components/sidebar.html" %}
{% endblock %} ================================================ FILE: admin_frontend/templates/pages/login.html ================================================ {% extends "layouts/base.html" %} {% block title %} AppFlowy Cloud Login {% endblock %} {% block head %} {% endblock %} {% block content %}
{% include "../assets/logo.html" %}

AppFlowy Cloud

Email Login

Workspace Name Members Document Storage Object Storage
{{ workspace_usage.name|escape }} {{ workspace_usage.member_count|escape }} {{ workspace_usage.total_doc_size|escape }} {{ workspace_usage.total_blob_size|escape }}
{% if let Some(redirect_to) = redirect_to %} {% endif %}
Email
Password  
(Magic link will be sent to email if password is not provided)
{% if oauth_providers.len() > 0 %}

 or 

OAuth Login

{% for provider in oauth_providers %}
  {{ provider }}
{% endfor %}
{% endif %}  
kofi   Support AppFlowy on Ko-fi
 
  By clicking logging in or signing up, you confirm that you have read, understood, and agreed to AppFlowy's Terms and Privacy Policy.
{% endblock %} ================================================ FILE: admin_frontend/templates/pages/login_callback.html ================================================ ================================================ FILE: admin_frontend/templates/pages/login_v2.html ================================================ {% extends "layouts/base.html" %} {% block title %} AppFlowy Cloud Login {% endblock %} {% block head %} {% endblock %} {% block content %}
{% include "../assets/logo.html" %}

Welcome to AppFlowy

{% if let Some(redirect_to) = redirect_to %} {% endif %}
{% if oauth_providers.len() > 0 %}

 or 
{% for provider in oauth_providers %} {% endfor %}
{% endif %}      
By clicking "Continue" above, you agreed to AppFlowy's Terms and Privacy Policy.
{% endblock %}
================================================ FILE: admin_frontend/templates/pages/open_appflowy_or_download.html ================================================ AppFlowy

Opening AppFlowy

If AppFlowy does not open, you can click here to launch the app.

If AppFlowy is not installed, you can download AppFlowy manually.

================================================ FILE: admin_frontend/templates/pages/payment_success_redirect.html ================================================ AppFlowy - Redirecting ================================================ FILE: admin_frontend/templates/pages/redirect.html ================================================ Redirecting...

If you are not redirected, click here.

================================================ FILE: admin_frontend/tests/main.rs ================================================ mod oauth; mod utils; ================================================ FILE: admin_frontend/tests/oauth/mod.rs ================================================ use admin_frontend::models::OAuthRedirect; use admin_frontend::models::OAuthRedirectToken; use base64::engine::Engine; use base64::prelude::BASE64_STANDARD_NO_PAD; use gotrue_entity::dto::GotrueTokenResponse; use reqwest::StatusCode; use reqwest::Url; use sha2::Digest; use crate::utils::AdminFrontendClient; #[tokio::test] async fn oauth_sign_in() { let mut af_client = AdminFrontendClient::new(); af_client .web_api_sign_in("admin@example.com", "password") .await; let code_challenge_orginal = "hello123"; let code_challenge_sha256 = { let mut hasher = sha2::Sha256::new(); hasher.update(code_challenge_orginal.as_bytes()); hasher.finalize().to_vec() }; // OAuth Param let code_challenge = BASE64_STANDARD_NO_PAD.encode(code_challenge_sha256); let client_id = "appflowy_cloud"; let state = "state123"; { // redirect url not in allowed list let resp = af_client .web_api_oauth_redirect(&OAuthRedirect { client_id: client_id.to_string(), state: state.to_string(), redirect_uri: "https://mywebsite.com".to_string(), response_type: "code".to_string(), code_challenge: Some(code_challenge.clone()), code_challenge_method: Some("S256".to_string()), }) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!( resp.text().await.unwrap(), "invalid redirect_uri: https://mywebsite.com, allowable_uris: http://localhost:3000" ); } { let resp = af_client .web_api_oauth_redirect(&OAuthRedirect { client_id: client_id.to_string(), state: state.to_string(), redirect_uri: "http://localhost:3000".to_string(), response_type: "code".to_string(), code_challenge: Some(code_challenge.clone()), code_challenge_method: Some("S256".to_string()), }) .await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); let redirect_url = resp.headers().get("location").unwrap().to_str().unwrap(); let (code, ret_state) = extract_code_and_state(redirect_url); assert_eq!(ret_state, state); { // did not provide code_verifier let resp = af_client .web_api_oauth_redirect_token(&OAuthRedirectToken { code: code.clone(), grant_type: "authorization_code".to_string(), ..Default::default() }) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!(resp.text().await.unwrap(), "missing code_verifier"); } { let resp = af_client .web_api_oauth_redirect_token(&OAuthRedirectToken { code, grant_type: "authorization_code".to_string(), code_verifier: Some(code_challenge_orginal.to_string()), ..Default::default() }) .await; assert_eq!(resp.status(), StatusCode::OK); let token_str = resp.text().await.unwrap(); let _gotrue_token: GotrueTokenResponse = serde_json::from_str(&token_str).unwrap(); } } } fn extract_code_and_state(url_str: &str) -> (String, String) { // Parse the URL let url = Url::parse(url_str).expect("Failed to parse URL"); // Extract the query parameters let code = url .query_pairs() .find(|(key, _)| key == "code") .map(|(_, value)| value.to_string()) .unwrap_or_else(|| "code not found".to_string()); let state = url .query_pairs() .find(|(key, _)| key == "state") .map(|(_, value)| value.to_string()) .unwrap_or_else(|| "state not found".to_string()); (code, state) } ================================================ FILE: admin_frontend/tests/utils/mod.rs ================================================ pub mod test_config; use admin_frontend::{ config::Config, models::{OAuthRedirect, OAuthRedirectToken, WebApiLoginRequest}, }; use test_config::TestConfig; pub struct AdminFrontendClient { test_config: TestConfig, #[allow(dead_code)] server_config: Config, session_id: Option, http_client: reqwest::Client, } impl AdminFrontendClient { pub fn new() -> Self { dotenvy::dotenv().ok(); let server_config = Config::from_env().unwrap(); let test_config = TestConfig::from_env(); let http_client = reqwest::Client::new(); Self { server_config, session_id: None, http_client, test_config, } } pub async fn web_api_sign_in(&mut self, email: &str, password: &str) { let url = format!( "{}{}/web-api/signin", self.test_config.hostname, self.server_config.path_prefix ); let resp = self .http_client .post(&url) .form(&WebApiLoginRequest { email: email.to_string(), password: password.to_string(), redirect_to: None, }) .send() .await .unwrap(); let resp = check_resp(resp).await; let c = resp.cookies().find(|c| c.name() == "session_id").unwrap(); self.session_id = Some(c.value().to_string()); } pub async fn web_api_oauth_redirect( &mut self, oauth_redirect: &OAuthRedirect, ) -> reqwest::Response { let url = format!( "{}{}/web-api/oauth-redirect", self.test_config.hostname, self.server_config.path_prefix ); let http_client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); http_client .get(&url) .header("Cookie", format!("session_id={}", self.session_id())) .query(oauth_redirect) .send() .await .unwrap() } pub async fn web_api_oauth_redirect_token( &mut self, oauth_redirect: &OAuthRedirectToken, ) -> reqwest::Response { let url = format!( "{}{}/web-api/oauth-redirect/token", self.test_config.hostname, self.server_config.path_prefix ); self .http_client .get(&url) .header("Cookie", format!("session_id={}", self.session_id())) .query(oauth_redirect) .send() .await .unwrap() } fn session_id(&self) -> &str { self.session_id.as_ref().unwrap() } } async fn check_resp(resp: reqwest::Response) -> reqwest::Response { if resp.status() != 200 { println!("resp: {:#?}", resp); let payload = resp.text().await.unwrap(); panic!("payload: {:#?}", payload) } resp } ================================================ FILE: admin_frontend/tests/utils/test_config.rs ================================================ pub struct TestConfig { pub hostname: String, } impl TestConfig { pub fn from_env() -> Self { dotenvy::dotenv().ok(); let hostname = std::env::var("ADMIN_FRONTEND_TEST_HOSTNAME").unwrap_or("http://localhost:3000".to_string()); TestConfig { hostname } } } ================================================ FILE: assets/mailer_templates/build_production/access_request.html ================================================ Request to join the workspace
Approve a user's request to join the workspace. &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
{{ username }}

{{ username }} has requested access to {{ workspace_name }}

{{ workspace_name }}
{{ workspace_name }}
{{ workspace_member_count }} members
By clicking "Approve request" above, the user will be added to the workspace.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: assets/mailer_templates/build_production/access_request_approved_notification.html ================================================ Your access request has been approved
Workspace access request approved notification &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;

Your request to access {{ workspace_name }} has been approved

{{ workspace_name }}
{{ workspace_name }}
{{ workspace_member_count }} members
By clicking "View workspace" above, you confirm that you have read, understood, and agreed to AppFlowy's Terms & Conditions and Privacy Policy.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: assets/mailer_templates/build_production/confirmation.html ================================================ New sign up for AppFlowy
To login to AppFlowy, follow this link {{ .ConfirmationURL }}

Login for AppFlowy

We have received a request to confirm your AppFlowy account.

You can log in using either of the following options:

Option 1: Magic Link (Fast & Easy)

Click the button or link below to log in instantly

Login to AppFlowy

Or paste this into your browser:

{{ .ConfirmationURL }}

Option 2: One-Time Password (OTP)

Prefer to enter a code instead? Use the one-time code below

{{ .Token }}

This code and magic link will expire in 5 minutes for security reasons.

If you didn't initiate this login, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: assets/mailer_templates/build_production/import_data_fail.html ================================================ Workspace Import Failed
There was an issue with your workspace import &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;

Notion Import Failed

{{ error }}

Join our Discord server to get quick help or report the issue on GitHub

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: assets/mailer_templates/build_production/import_data_success.html ================================================ Workspace Import Success
Your workspace import was successful &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;

Notion Import Complete

Your Notion data has been successfully imported into

{{ workspace_name }}

{{ workspace_name }}
{{ workspace_name }}
1 member

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: assets/mailer_templates/build_production/magic_link.html ================================================ Login for AppFlowy
To login to AppFlowy, follow this link {{ .ConfirmationURL }}

Login for AppFlowy

We received a request to log in to your AppFlowy account.

You can log in using either of the following options:

Option 1: Magic Link (Fast & Easy)

Click the button or link below to log in instantly

Login to AppFlowy

Or paste this into your browser:

{{ .ConfirmationURL }}

Option 2: One-Time Password (OTP)

Prefer to enter a code instead? Use the one-time code below

{{ .Token }}

This code and magic link will expire in 5 minutes for security reasons.

If you didn't initiate this login, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: assets/mailer_templates/build_production/page_mention_notification.html ================================================

AppFlowy

{{ mentioner_name }} has mentioned you in {{ mentioned_page_name }}

{{ mentioned_at }}

{{ workspace_name }} / ... / {{ mentioned_page_name }}


Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter YouTube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

undefined
================================================ FILE: assets/mailer_templates/build_production/recovery.html ================================================ AppFlowy Password Recovery
To reset your password, enter the code below in the app:

Reset your password

Someone recently requested a password reset for your AppFlowy account. If this was you, use the following verification code.

{{ .Token }}

This code will expire in 5 minutes for security reasons.

If you didn't initiate this recovery, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: assets/mailer_templates/build_production/workspace_invitation.html ================================================ Confirm to join the workspace
Please confirm your email address to join the workspace. &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
{{ username }}

{{ username }} invited you to {{ workspace_name }}

{{ workspace_name }}
{{ workspace_name }}
{{ workspace_member_count }} members
By clicking "Join workspace" above, you confirm that you have read, understood, and agreed to AppFlowy's Terms & Conditions and Privacy Policy.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: assets/mailer_templates/confirmation.html ================================================ Email Confirmation

Log in to AppFlowy


Click the button below to securely log in or sign up. This magic link will expire in 5 minutes.

Log in / Sign up

Confirming this request will securely log you in.

================================================ FILE: deny.toml ================================================ [advisories] ignore = [ "RUSTSEC-2024-0384", "RUSTSEC-2025-0012", ] ================================================ FILE: deploy.env ================================================ # ============================================================================= # AppFlowy Cloud - Production Deployment Configuration # ============================================================================= # This file is a template for docker compose deployment # Copy this file to .env and change the values as needed # Fully qualified domain name for the deployment. Replace localhost with your domain, # such as mydomain.com. FQDN=localhost # Change this to https if you are using TLS. SCHEME=http # Change this to wss if you are using TLS WS_SCHEME=ws APPFLOWY_BASE_URL=${SCHEME}://${FQDN} APPFLOWY_WEBSOCKET_BASE_URL=${WS_SCHEME}://${FQDN}/ws/v2 # ============================================================================= # 🗄️ DATABASE & CACHE: Core data infrastructure # ============================================================================= # PostgreSQL Settings POSTGRES_HOST=postgres POSTGRES_USER=postgres POSTGRES_PASSWORD=password POSTGRES_PORT=5432 POSTGRES_DB=postgres # Redis Settings REDIS_HOST=redis REDIS_PORT=6379 # ============================================================================= # 🏗️ INFRASTRUCTURE SERVICES: Object storage and networking # ============================================================================= # MinIO Configuration: S3-compatible object storage for file uploads and attachments # Docker service discovery: These values are used for container-to-container communication # MINIO_HOST refers to the Docker Compose service name, not an external domain/IP # Used by: AppFlowy Cloud, Worker services, AI service, and Admin Frontend MINIO_HOST=minio MINIO_PORT=9000 # MinIO/AWS Credentials: Authentication keys for object storage access # Development: Uses MinIO's default credentials (minioadmin/minioadmin) for quick setup # Production: MUST be changed to secure, randomly generated credentials for security # These credentials are used across all services that access file storage # Security note: Default credentials are well-known and should never be used in production AWS_ACCESS_KEY=minioadmin AWS_SECRET=minioadmin # ============================================================================= # ☁️ APPFLOWY SERVICES: Application service configuration # ============================================================================= # AppFlowy Cloud Service Configuration # URL that connects to the gotrue docker container APPFLOWY_GOTRUE_BASE_URL=http://gotrue:9999 # URL that connects to the postgres docker container. If your password contains special characters, # instead of using ${POSTGRES_PASSWORD}, you will need to convert them into url encoded format. # For example, `p@ssword` will become `p%40ssword`. APPFLOWY_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} # AppFlowy Service Configuration # Access Control System: Enables/disables permission-based access control # Controls workspace access, collaboration permissions, and realtime access restrictions APPFLOWY_ACCESS_CONTROL=true # WebSocket Mailbox Configuration: Controls realtime server message handling capacity # Sets the maximum number of messages that can be queued in the WebSocket actor's mailbox # Higher values allow more concurrent WebSocket messages but use more memory # Lower values may cause message drops under high load but reduce memory usage APPFLOWY_WEBSOCKET_MAILBOX_SIZE=6000 # Database Connection Pool: Maximum number of concurrent PostgreSQL connections # Controls the size of the database connection pool for the AppFlowy Cloud service # PostgreSQL has a default limit of ~100 connections total (15 reserved for superuser) # Higher values improve concurrency but consume more database resources # Lower values reduce database load but may cause connection timeouts under load APPFLOWY_DATABASE_MAX_CONNECTIONS=40 # URL that connects to the redis docker container APPFLOWY_REDIS_URI=redis://${REDIS_HOST}:${REDIS_PORT} # GoTrue database connection. If your password contains special characters, # instead of using ${POSTGRES_PASSWORD}, use the url encoded version. # For example, `p@ssword` will become `p%40ssword` GOTRUE_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?search_path=auth # ============================================================================= # 🔐 GOTRUE: Authentication service configuration # ============================================================================= # GoTrue Admin Credentials # This user will be created when GoTrue starts successfully # You can use this user to login to the admin panel GOTRUE_ADMIN_EMAIL=admin@example.com GOTRUE_ADMIN_PASSWORD=password # JWT Configuration # Authentication key, change this and keep the key safe and secret GOTRUE_JWT_SECRET=hello456 # Expiration time in seconds for the JWT token GOTRUE_JWT_EXP=604800 # External URL where the GoTrue service is exposed API_EXTERNAL_URL=${APPFLOWY_BASE_URL}/gotrue # User Registration & Login Settings # User sign up will automatically be confirmed if this is set to true. # If you have OAuth2 set up or smtp configured, you can set this to false # to enforce email confirmation or OAuth2 login instead. # If you set this to false, you need to either set up SMTP GOTRUE_MAILER_AUTOCONFIRM=true # Set this to true if users can only join by invite GOTRUE_DISABLE_SIGNUP=false # Number of emails that can be sent per minute GOTRUE_RATE_LIMIT_EMAIL_SENT=100 # Email Templates # Optional. You can provide a public http link (eg. github) to customize your magic link template. # Refer to https://github.com/supabase/auth?tab=readme-ov-file#configuration for details on how to create a custom email template. GOTRUE_MAILER_TEMPLATES_MAGIC_LINK= # ============================================================================= # 📧 EMAIL CONFIGURATION: SMTP settings (optional but recommended for production) # ============================================================================= # If you intend to use mail confirmation, you need to set the SMTP configuration below # You would then need to set GOTRUE_MAILER_AUTOCONFIRM=false # Check for logs in gotrue service if there are any issues with email confirmation # Note that smtps will be used for port 465, otherwise plain smtp with optional STARTTLS GOTRUE_SMTP_HOST=smtp.gmail.com GOTRUE_SMTP_PORT=465 GOTRUE_SMTP_USER=email_sender@some_company.com GOTRUE_SMTP_PASS=email_sender_password GOTRUE_SMTP_ADMIN_EMAIL=comp_admin@some_company.com # AppFlowy Cloud Mailer # Note that smtps (TLS) is always required, even for ports other than 465 APPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com APPFLOWY_MAILER_SMTP_PORT=465 APPFLOWY_MAILER_SMTP_USERNAME=email_sender@some_company.com APPFLOWY_MAILER_SMTP_EMAIL=email_sender@some_company.com APPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password APPFLOWY_MAILER_SMTP_TLS_KIND=wrapper # "none" "wrapper" "required" "opportunistic" # ============================================================================= # 🔑 OAUTH PROVIDERS: Third-party authentication (optional) # ============================================================================= # Refer to this for details: https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/AUTHENTICATION.md # Google OAuth2 GOTRUE_EXTERNAL_GOOGLE_ENABLED=false GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID= GOTRUE_EXTERNAL_GOOGLE_SECRET= GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${API_EXTERNAL_URL}/callback # GitHub OAuth2 GOTRUE_EXTERNAL_GITHUB_ENABLED=false GOTRUE_EXTERNAL_GITHUB_CLIENT_ID= GOTRUE_EXTERNAL_GITHUB_SECRET= GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${API_EXTERNAL_URL}/callback # Discord OAuth2 GOTRUE_EXTERNAL_DISCORD_ENABLED=false GOTRUE_EXTERNAL_DISCORD_CLIENT_ID= GOTRUE_EXTERNAL_DISCORD_SECRET= GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${API_EXTERNAL_URL}/callback # Apple OAuth2 GOTRUE_EXTERNAL_APPLE_ENABLED=false GOTRUE_EXTERNAL_APPLE_CLIENT_ID= GOTRUE_EXTERNAL_APPLE_SECRET= GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=${API_EXTERNAL_URL}/callback # SAML 2.0. Refer to https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/OKTA_SAML.md for example using Okta. GOTRUE_SAML_ENABLED=false GOTRUE_SAML_PRIVATE_KEY= # ============================================================================= # 💾 FILE STORAGE: S3/MinIO configuration (required for file uploads) # ============================================================================= # Storage Architecture Control: Determines the file storage backend for the entire system # Affects: User uploads, document attachments, collaboration snapshots, AI embeddings, import/export files # When true: Uses MinIO (S3-compatible) with path-style URLs and MinIO endpoint configuration # When false: Uses AWS S3 with region-based configuration and standard S3 URLs # Production options: Keep true for self-hosted MinIO, set false for AWS S3 APPFLOWY_S3_USE_MINIO=true # Bucket Management: Controls automatic bucket creation during AppFlowy startup # When true: AppFlowy automatically creates the storage bucket if it doesn't exist # When false: Assumes bucket exists and was created externally (recommended for production) APPFLOWY_S3_CREATE_BUCKET=true # MinIO Endpoint Configuration: URL for MinIO API access # Uses Docker service discovery variables for container networking # Format combines MINIO_HOST and MINIO_PORT for internal service communication # Change this URL if using external MinIO instance or different networking setup APPFLOWY_S3_MINIO_URL=http://${MINIO_HOST}:${MINIO_PORT} # Storage Authentication: Maps to the MinIO/AWS credentials defined above # These reference the AWS_ACCESS_KEY and AWS_SECRET variables for consistency # All AppFlowy services use these credentials to access the file storage backend APPFLOWY_S3_ACCESS_KEY=${AWS_ACCESS_KEY} APPFLOWY_S3_SECRET_KEY=${AWS_SECRET} # Storage Bucket: Default bucket name for all AppFlowy file storage # Contains: User files, document attachments, collaboration data, AI embeddings # Must exist in both MinIO and AWS S3 configurations APPFLOWY_S3_BUCKET=appflowy # AWS S3 Configuration: Required only when APPFLOWY_S3_USE_MINIO=false # Uncomment and configure these settings when using AWS S3 instead of MinIO # APPFLOWY_S3_REGION=us-east-1 # MinIO Presigned URL Endpoint: External URL for client-side file access (optional) # Enables direct file uploads/downloads from AppFlowy clients through presigned URLs # Set this to your public MinIO endpoint if using nginx proxy configuration # Format: Uses the external base URL with /minio-api path for API access APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=${APPFLOWY_BASE_URL}/minio-api # ============================================================================= # 🤖 AI FEATURES: Optional AI capabilities (configure only if needed) # ============================================================================= # AppFlowy AI # OpenAI API Authentication: Required API key for AI-powered features and semantic search # Controls access to OpenAI's embedding models (text-embedding-3-small) for document indexing # and ChatGPT models (gpt-4o-mini default) for search result summarization # When configured: Enables semantic document search, AI-powered search summaries, and document embeddings # When empty: AI features are disabled but core AppFlowy functionality remains fully operational AI_OPENAI_API_KEY= # If no summary model is provided, there will be no search summary when using AI search. AI_OPENAI_API_SUMMARY_MODEL= # Azure-hosted OpenAI API: # If you're using a self-hosted OpenAI API via Azure, leave AI_OPENAI_API_KEY empty # and set the following Azure-specific variables instead. If both are set, the standard OpenAI API will be used. AI_AZURE_OPENAI_API_KEY= AI_AZURE_OPENAI_API_BASE= AI_AZURE_OPENAI_API_VERSION= # AI Service Configuration (Docker container defaults) AI_SERVER_PORT=5001 AI_SERVER_HOST=ai AI_DATABASE_URL=postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} AI_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT} AI_APPFLOWY_BUCKET_NAME=${APPFLOWY_S3_BUCKET} AI_APPFLOWY_HOST=${APPFLOWY_BASE_URL} AI_MINIO_URL=http://${MINIO_HOST}:${MINIO_PORT} # Embedding Configuration APPFLOWY_EMBEDDING_CHUNK_SIZE=2000 APPFLOWY_EMBEDDING_CHUNK_OVERLAP=200 # ============================================================================= # ⚙️ WORKER SERVICES: Background processing (good defaults for production) # ============================================================================= # AppFlowy Indexer (for search functionality) APPFLOWY_INDEXER_ENABLED=true APPFLOWY_INDEXER_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} APPFLOWY_INDEXER_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT} APPFLOWY_INDEXER_EMBEDDING_BUFFER_SIZE=5000 # AppFlowy Collaboration Service Configuration: # Controls real-time collaboration behavior and performance # Multi-thread: Whether collaboration service uses multiple threads (can be true for production) # When deployed as standalone service, can be set to true for better performance APPFLOWY_COLLABORATE_MULTI_THREAD=false # Remove batch size: Number of inactive collaboration groups to remove in a single batch (default: 100) # Higher values improve cleanup efficiency but may cause temporary blocking APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100 # AppFlowy Worker Service APPFLOWY_WORKER_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT} APPFLOWY_WORKER_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} APPFLOWY_WORKER_DATABASE_NAME=${POSTGRES_DB} # ============================================================================= # Real Time Transcription # ============================================================================= ASSEMBLYAI_API_KEY= ASSEMBLYAI_API_BASE=https://api.assemblyai.com/v2 ASSEMBLYAI_STREAMING_API_BASE=https://streaming.assemblyai.com/v3 # ============================================================================= # 🌐 WEB FRONTEND: AppFlowy Web interface # ============================================================================= # AppFlowy Web # If your AppFlowy Web is hosted on a different domain, update this variable to the correct domain APPFLOWY_WEB_URL=${APPFLOWY_BASE_URL} # If you are running AppFlowy Web locally for development purpose, use the following value instead # APPFLOWY_WEB_URL=http://localhost:3000 # ============================================================================= # 🗄️ PGADMIN: Database Management Web Interface # ============================================================================= # PgAdmin credentials for database management web UI # You can access pgadmin at http://your-host/pgadmin # Use the APPFLOWY_DATABASE_URL values when connecting to the database PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_PASSWORD=password # ============================================================================= # 🌐 NGINX: Reverse proxy and web server configuration # ============================================================================= # NGINX Configuration # Optional, change this if you want to use custom ports to expose AppFlowy NGINX_PORT=80 NGINX_TLS_PORT=443 # ============================================================================= # 🛠️ INFRASTRUCTURE: Networking, logging, and admin tools # ============================================================================= # Log level for the appflowy-cloud service RUST_LOG=info # Cloudflare Tunnel (Advanced Networking) # Leave empty unless you're using Cloudflare tunnel for secure connections CLOUDFLARE_TUNNEL_TOKEN= # Enable AI tests in production environment (usually false) # Set to true only if you want to run AI-related tests in production AI_TEST_ENABLED=false ================================================ FILE: dev.env ================================================ # ============================================================================= # AppFlowy Cloud - Development Environment Configuration # ============================================================================= # This file is used to set the environment variables for local development # Copy this file to .env and change the values as needed # ============================================================================= # 🗄️ DATABASE & CACHE: Core data infrastructure # ============================================================================= # URL for sqlx DATABASE_URL=postgres://postgres:password@localhost:5432/postgres # Uncomment this to enable build without database # .sqlx files must be pregenerated # SQLX_OFFLINE=true # ============================================================================= # ☁️ APPFLOWY SERVICES: Application service configuration # ============================================================================= # GoTrue URL that the appflowy service will use to connect to gotrue APPFLOWY_GOTRUE_BASE_URL=http://localhost:9999 APPFLOWY_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres APPFLOWY_ACCESS_CONTROL=true APPFLOWY_WEBSOCKET_MAILBOX_SIZE=6000 APPFLOWY_DATABASE_MAX_CONNECTIONS=40 APPFLOWY_DOCUMENT_CONTENT_SPLIT_LEN=8000 # ============================================================================= # 🔐 GOTRUE: Authentication service configuration # ============================================================================= # GoTrue Admin Credentials # Admin user for accessing the admin panel GOTRUE_ADMIN_EMAIL=admin@example.com GOTRUE_ADMIN_PASSWORD=password # JWT Configuration # Authentication key, change this and keep the key safe and secret GOTRUE_JWT_SECRET=hello456 # Expiration time in seconds for the JWT token GOTRUE_JWT_EXP=604800 # External URL where the GoTrue service is exposed # The email verification link provided to users will redirect them to this specified host # For instance, if you're running your application locally using 'docker compose up -d', # you can set this value to 'http://localhost' API_EXTERNAL_URL=http://localhost:9999 # GoTrue Database Connection # Database URL that gotrue will use GOTRUE_DATABASE_URL=postgres://postgres:password@postgres:5432/postgres?search_path=auth # User Registration & Login Settings # User sign up will automatically be confirmed if this is set to true # If you have OAuth2 set up or smtp configured, you can set this to false # to enforce email confirmation or OAuth2 login instead GOTRUE_MAILER_AUTOCONFIRM=false # Set this to true if users can only join by invite GOTRUE_DISABLE_SIGNUP=false # Email Rate Limiting # Number of emails that can be sent per minute GOTRUE_RATE_LIMIT_EMAIL_SENT=1000 # ============================================================================= # 📧 EMAIL CONFIGURATION: Optional (only configure if you need email features) # ============================================================================= # If you enable mail confirmation, you need to set the SMTP configuration below # Note that smtps will be used for port 465, otherwise plain smtp with optional STARTTLS GOTRUE_SMTP_HOST=smtp.gmail.com GOTRUE_SMTP_PORT=465 GOTRUE_SMTP_USER=email_sender@some_company.com GOTRUE_SMTP_PASS=email_sender_password GOTRUE_SMTP_ADMIN_EMAIL=comp_admin@some_company.com # Email template URLs for different types of emails GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/confirmation.html GOTRUE_MAILER_TEMPLATES_INVITE=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/invite.html GOTRUE_MAILER_TEMPLATES_RECOVERY=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/recovery.html GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/magic_link.html GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/email_change.html # AppFlowy Cloud Mailer # Note that smtps (TLS) is always required, even for ports other than 465 APPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com APPFLOWY_MAILER_SMTP_USERNAME=notify@appflowy.io APPFLOWY_MAILER_SMTP_EMAIL=notify@appflowy.io APPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password APPFLOWY_MAILER_SMTP_TLS_KIND=wrapper # "none" "wrapper" "required" "opportunistic" # ============================================================================= # 🔑 OAUTH PROVIDERS: Optional (configure only the ones you want to use) # ============================================================================= # Google OAuth2 GOTRUE_EXTERNAL_GOOGLE_ENABLED=true GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID= GOTRUE_EXTERNAL_GOOGLE_SECRET= GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=http://localhost:9999/callback # GitHub OAuth2 GOTRUE_EXTERNAL_GITHUB_ENABLED=false GOTRUE_EXTERNAL_GITHUB_CLIENT_ID= GOTRUE_EXTERNAL_GITHUB_SECRET= GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=http://localhost:9999/callback # Discord OAuth2 GOTRUE_EXTERNAL_DISCORD_ENABLED=false GOTRUE_EXTERNAL_DISCORD_CLIENT_ID= GOTRUE_EXTERNAL_DISCORD_SECRET= GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=http://localhost:9999/callback # Apple OAuth2 GOTRUE_EXTERNAL_APPLE_ENABLED=false GOTRUE_EXTERNAL_APPLE_CLIENT_ID= GOTRUE_EXTERNAL_APPLE_SECRET= GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=http://localhost:9999/callback # ============================================================================= # 🏗️ INFRASTRUCTURE SERVICES: Object storage and networking # ============================================================================= # AWS credentials (used for MinIO in development) AWS_ACCESS_KEY=minioadmin AWS_SECRET=minioadmin # ============================================================================= # 🎛️ ADMIN FRONTEND: Management interface configuration # ============================================================================= # URL that connects to redis for admin frontend ADMIN_FRONTEND_REDIS_URL=redis://localhost:6379 # URL that connects to gotrue service for admin frontend ADMIN_FRONTEND_GOTRUE_URL=http://localhost:9999 # URL that connects to the appflowy cloud service for admin frontend ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=http://localhost:8000 # Base URL path for the admin frontend (usually /console for production, can be empty for development) ADMIN_FRONTEND_PATH_PREFIX= # ============================================================================= # 💾 FILE STORAGE: Local MinIO (works out-of-the-box for development) # ============================================================================= # File Storage APPFLOWY_S3_CREATE_BUCKET=true APPFLOWY_S3_USE_MINIO=true APPFLOWY_S3_MINIO_URL=http://localhost:9000 # change this if you are using a different address for minio" APPFLOWY_S3_ACCESS_KEY=${AWS_ACCESS_KEY} APPFLOWY_S3_SECRET_KEY=${AWS_SECRET} APPFLOWY_S3_BUCKET=appflowy # APPFLOWY_S3_REGION=us-east-1 # ============================================================================= # 🤖 AI FEATURES: Optional (configure only if you want AI functionality) # ============================================================================= # AppFlowy AI # Standard OpenAI API: # Set your API key here if you are using the standard OpenAI API. AI_OPENAI_API_KEY= # If no summary model is provided, there will be no search summary when using AI search. AI_OPENAI_API_SUMMARY_MODEL="gpt-4o-mini" # Azure-hosted OpenAI API: # If you're using a self-hosted OpenAI API via Azure, leave AI_OPENAI_API_KEY empty # and set the following Azure-specific variables instead. If both are set, the standard OpenAI API will be used. AI_AZURE_OPENAI_API_KEY= AI_AZURE_OPENAI_API_BASE= AI_AZURE_OPENAI_API_VERSION= AI_SERVER_PORT=5001 AI_SERVER_HOST=localhost AI_DATABASE_URL=postgresql+psycopg://postgres:password@localhost:5432/postgres AI_REDIS_URL=redis://localhost:6379 AI_APPFLOWY_BUCKET_NAME=${APPFLOWY_S3_BUCKET} AI_APPFLOWY_HOST=http://localhost:8000 AI_MINIO_URL=http://localhost:9000 # Embedding Configuration APPFLOWY_EMBEDDING_CHUNK_SIZE=500 APPFLOWY_EMBEDDING_CHUNK_OVERLAP=50 # ============================================================================= # ⚙️ WORKER SERVICES: Background processing (good defaults for development) # ============================================================================= # AppFlowy Indexer (for search functionality) APPFLOWY_INDEXER_ENABLED=true APPFLOWY_INDEXER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres APPFLOWY_INDEXER_REDIS_URL=redis://localhost:6379 APPFLOWY_INDEXER_EMBEDDING_BUFFER_SIZE=5000 # AppFlowy Worker APPFLOWY_WORKER_REDIS_URL=redis://localhost:6379 APPFLOWY_WORKER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres # ============================================================================= # 🌐 WEB FRONTEND: AppFlowy Web interface # ============================================================================= # AppFlowy Web APPFLOWY_WEB_URL=http://localhost:3000 # ============================================================================= # 🗄️ PGADMIN: Database Management Web Interface # ============================================================================= # PgAdmin credentials for database management web UI # You can access pgadmin at http://localhost/pgadmin when running with docker-compose # Use the DATABASE_URL values when connecting to the database PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_PASSWORD=password # ============================================================================= # 🛠️ DEVELOPMENT TOOLS: Database admin, monitoring, etc. # ============================================================================= # Log level for the application RUST_LOG=info # Cloudflare tunnel token CLOUDFLARE_TUNNEL_TOKEN= # Enable AI tests in development/CI environment # In GitHub CI, this is enabled via the 'ai-test-enabled' feature flag # Set to true to run AI-related tests locally (requires valid API keys) AI_TEST_ENABLED=false ================================================ FILE: doc/AUTHENTICATION.md ================================================ # Authentication Follow [this](https://appflowy.com/docs/Authentication) guide to set up ================================================ FILE: doc/CONTRIBUTING.md ================================================ # Contributing First of all, thank you for contributing to AppFlowy Cloud! The goal of this document is to provide everything you need to know in order to contribute to AppFlowy Cloud and its different integrations. - [Assumptions](#assumptions) - [How to Contribute](#how-to-contribute) - [Development Workflow](#development-workflow) ## Assumptions 1. **You're familiar with [GitHub](https://github.com) and the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)( PR) workflow.** 2. **You know about the [AppFlowy community](https://discord.gg/9Q2xaN37tV). Please use this for help.** ## How to Contribute Contributions are welcome! Here's how you can help improve AppFlowy Cloud: 1. Identify or propose enhancements or fixes by checking [existing issues](https://github.com/AppFlowy-IO/AppFlowy-Cloud/issues) or [creating a new one](https://github.com/AppFlowy-IO/AppFlowy-Cloud/issues/new/choose). 2. [Fork the repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) to your own GitHub account. Feel free to discuss your contribution with a maintainer beforehand. 3. [Create a feature or bugfix branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository) in your forked repo. 4. Familiarize yourself with the [Development Workflow](#development-workflow) for guidelines on maintaining code quality. 5. Implement your changes on the new branch. 6. [Open a Pull Request (PR)](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) against the `main` branch of the original AppFlowy Cloud repo. Await feedback or approval from the maintainers. ## Development Workflow Before diving into development, familiarize yourself with the codebase and project standards by reviewing the [Development Guide](./GUIDE.md). ### Setting Up the Local Server To start the server on your local machine, run the following script: ```bash ./script/run_local_server.sh ``` ### Testing Verify that your changes work as expected by running the test suite: ```bash cargo test ``` ### Pull Request (PR) Requirements For a pull request to be accepted, it must satisfy the following criteria: 1. **Pass All Tests**: Your PR should not break any existing functionality and must pass all the automated tests. 2. **Linting with Clippy**: Your code must adhere to the linting standards enforced by [`clippy`](https://github.com/rust-lang/rust-clippy). You can check for linting issues using: ```bash cargo clippy -- -D warnings ``` If `clippy` isn't already installed: ```bash rustup update rustup component add clippy ``` 3. **Code Formatting**: The code must comply with established formatting rules. Use the following commands for formatting and checking your code: To format your code: ```bash cargo fmt ``` To validate formatting: ```bash cargo fmt --all -- --check ``` ================================================ FILE: doc/DEPLOYMENT.md ================================================ # Deployment Step-by-step Self Hosting Guides - From Zero to Production ([link](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production)) ================================================ FILE: doc/EC2_SELF_HOST_GUIDE.md ================================================ # Installing AppFlowy-Cloud on an AWS EC2 Ubuntu Instance Follow the guide [here](https://appflowy.com/docs/Installing-AppFlowy-Cloud-on-AWS-EC2) ================================================ FILE: doc/GUIDE.md ================================================ # AppFlowy Cloud: Comprehensive Guide ## Overview of File Structure ### Libraries (`libs`) - `libs/client-api`: API client for interfacing with AppFlowy-Cloud. - `libs/database`: Houses database schema and migration scripts. - `libs/database-entity`: Definitions for database entities. - `libs/gotrue`: Contains the GoTrue Authentication Server code. - `libs/gotrue-entity`: Entity definitions for the GoTrue Auth Server. - `libs/realtime`: Realtime server implementation. - `libs/collab-rt-entity`: Realtime server entity definitions. - `libs/infra`: Scripts and tools for infrastructure management. - `libs/app_error`: Custom error types specific to AppFlowy-Cloud. ### Source Code (`src`) - `src/api`: Endpoints and handlers for the AppFlowy-Cloud API. - `src/biz`: Core business logic of the application. - `src/middleware`: Middleware components for API processing. ### Configuration and Migration - `configurations`: Contains essential configuration files for various services. - `migrations`: Scripts for managing and migrating the Postgres database. ## Service Routing and Access ### Access Points Post Deployment After executing `docker compose up -d`, AppFlowy-Cloud is accessible at `http://localhost` on ports 80 and 443 with the following routing: - `/gotrue`: Redirects to the GoTrue Auth Server. - `/api`: AppFlowy-Cloud's HTTP API endpoint. - `/ws`: WebSocket endpoint for AppFlowy-Cloud. - `/console`: User Admin Frontend for AppFlowy. - `/pgadmin`: Interface for Postgres database management. - `/minio`: User interface for Minio object storage. - `/`, `/app`: AppFlowy Web. ![Deployment Architecture](../assets/images/deployment_arch.png) ## Dockerization and Continuous Integration #### Docker Images AppFlowy leverages Docker for efficient deployment and scaling. Docker images are available at: - `appflowy_cloud`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/appflowy_cloud/general) - `admin_frontend`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/admin_frontend/general) - `appflowy_web`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/appflowy_web/general) #### Automated Builds with GitHub Tags The Docker images are automatically built and updated through a GitHub Actions workflow: 1. **Tag Creation**: A new tag in the GitHub repository indicates a new version or release. 2. **Automated Build Trigger**: This tag initiates the Docker image building process via GitHub Actions. 3. **Docker Hub Updates**: The `appflowy_cloud` and `admin_frontend` images are updated on Docker Hub with the latest build. ================================================ FILE: doc/LOCAL_BUILD.md ================================================ # To build a multi-architecture Docker image Docker's buildx tool, which is a part of Docker BuildKit. This tool allows you to create images for different platforms from a single build command. Here's a basic rundown of the steps: 1. **Enable experimental features** by setting `"experimental": "enabled"` in your Docker configuration file (`~/.docker/config.json`). 2. **Install QEMU** on your macOS to emulate different architectures: ```sh brew install qemu ``` 3. **Create a new builder** that enables buildx and specify the platforms you want to target: ```sh docker buildx create --name mybuilder --use ``` 4. **Inspect the builder** to ensure it's correctly configured and can build for the target platforms: ```sh docker buildx inspect mybuilder --bootstrap ``` 5. **Build and push the image** to Docker Hub (or another registry) for the desired platforms using the `--platform` flag: ```sh docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t /myimage:latest --push . ``` ================================================ FILE: doc/OKTA_SAML.md ================================================ # Okta Authentication via SAML Follow [this](https://appflowy.com/docs/How-to-log-in-using-Okta-SAML-2) guide to set up ================================================ FILE: doc/README.md ================================================ # Docs - Directory to contain information about usage and development. - [Appflowy Cloud Deployment](./DEPLOYMENT.md) - [Appflowy with Cloud](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy) ================================================ FILE: docker/gotrue/Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM golang as base WORKDIR /go/src/supabase RUN git clone https://github.com/AppFlowy-IO/auth.git --depth 1 --branch 0.8.0 WORKDIR /go/src/supabase/auth RUN CGO_ENABLED=0 go build -o /auth . FROM alpine:3.20 RUN adduser -D -u 1000 supabase RUN apk add --no-cache ca-certificates curl USER supabase COPY --from=base /auth . COPY --from=base /go/src/supabase/auth/migrations ./migrations COPY start.sh . CMD ["./start.sh"] ================================================ FILE: docker/gotrue/start.sh ================================================ #!/usr/bin/env sh set -e ./auth migrate if [ -n "${GOTRUE_ADMIN_EMAIL}" ] && [ -n "${GOTRUE_ADMIN_PASSWORD}" ]; then set +e echo "Creating admin user for gotrue..." command_output=$(./auth admin createuser --admin --confirm "${GOTRUE_ADMIN_EMAIL}" "${GOTRUE_ADMIN_PASSWORD}" 2>&1) command_status=$? # Check if the command failed if [ $command_status -ne 0 ]; then # Check if the output contains the specific keyword if echo "$command_output" | grep -q "user already exists"; then echo "Admin user already exists. Skipping..." else echo "Command failed. Exiting." echo $command_output exit $command_status fi fi fi set -e ./auth ================================================ FILE: docker/pgadmin/servers.json ================================================ { "Servers": { "1": { "Name": "postgres", "Group": "Servers", "Host": "postgres", "Port": 5432, "MaintenanceDB": "postgres", "Username": "postgres" } } } ================================================ FILE: docker/web/Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM node:20.12.0 AS builder WORKDIR /app ARG VERSION=main RUN npm install -g pnpm@8.5.0 RUN git clone --depth 1 --branch ${VERSION} https://github.com/AppFlowy-IO/AppFlowy-Web.git . RUN pnpm install RUN sed -i 's|https://test.appflowy.cloud||g' src/components/main/app.hooks.ts RUN pnpm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/nginx.conf ================================================ FILE: docker/web/nginx.conf ================================================ worker_processes auto; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; # Basic optimization sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; # GZIP compression gzip on; gzip_vary on; gzip_min_length 1k; gzip_comp_level 6; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; server { listen 80; root /usr/share/nginx/html; index index.html; # Static files cache location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, no-transform"; } # SPA routing location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; } # Deny access to non public path location ~ /\. { deny all; } } } ================================================ FILE: docker-compose-ci.yml ================================================ # Essential services for AppFlowy Cloud services: nginx: restart: on-failure image: nginx ports: - ${NGINX_PORT:-80}:80 # Disable this if you are using TLS - ${NGINX_TLS_PORT:-443}:443 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./nginx/ssl/certificate.crt:/etc/nginx/ssl/certificate.crt - ./nginx/ssl/private_key.key:/etc/nginx/ssl/private_key.key networks: - shared_network #- ./nginx_logs:/var/log/nginx # You do not need this if you have configured to use your own s3 file storage # You can try to access http://localhost/minio/browser/appflowy in your browser minio: restart: on-failure image: minio/minio ports: - 9000:9000 - 9001:9001 environment: - MINIO_BROWSER_REDIRECT_URL=http://localhost/minio - MINIO_ROOT_USER=${APPFLOWY_S3_ACCESS_KEY:-minioadmin} - MINIO_ROOT_PASSWORD=${APPFLOWY_S3_SECRET_KEY:-minioadmin} command: server /data --console-address ":9001" volumes: - minio_data:/data networks: - shared_network postgres: restart: on-failure image: pgvector/pgvector:pg15 healthcheck: test: [ "CMD", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "${POSTGRES_DB}", "-p", "${POSTGRES_PORT:-5432}" ] interval: 5s timeout: 5s retries: 6 environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_DB=${POSTGRES_DB:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - POSTGRES_HOST=${POSTGRES_HOST:-postgres} - PGPORT=${POSTGRES_PORT:-5432} command: ["postgres", "-c", "port=${POSTGRES_PORT:-5432}"] networks: - shared_network redis: restart: on-failure image: redis ports: - "6379:6379" networks: - shared_network gotrue: restart: on-failure image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest} depends_on: postgres: condition: service_healthy healthcheck: test: "curl --fail http://127.0.0.1:9999/health || exit 1" interval: 5s timeout: 5s retries: 12 environment: # There are a lot of options to configure GoTrue. You can reference the example config: # https://github.com/supabase/auth/blob/master/example.env - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL} - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD} - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false} - GOTRUE_SITE_URL=appflowy-flutter:// # redirected to AppFlowy application - GOTRUE_URI_ALLOW_LIST=** # adjust restrict if necessary - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} # authentication secret - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP} # Without this environment variable, the createuser command will create an admin # with the `admin` role as opposed to `supabase_admin` - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin - GOTRUE_DB_DRIVER=postgres - API_EXTERNAL_URL=${API_EXTERNAL_URL} - DATABASE_URL=${GOTRUE_DATABASE_URL} - PORT=9999 - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST} # e.g. smtp.gmail.com - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT} # e.g. 465 - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER} # email sender, e.g. noreply@appflowy.io - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS} # email password - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/gotrue/verify - GOTRUE_MAILER_URLPATHS_INVITE=/gotrue/verify - GOTRUE_MAILER_URLPATHS_RECOVERY=/gotrue/verify - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/gotrue/verify - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL} # email with admin privileges e.g. internal@appflowy.io - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns} # set to 1ns for running tests - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false} # change this to true to skip email confirmation # Google OAuth config - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED} - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID} - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET} - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI} # GITHUB OAuth config - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED} - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID} - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET} - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI} # Discord OAuth config - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED} - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID} - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET} - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI} networks: - shared_network appflowy_cloud: restart: on-failure ports: - 8000:8000 environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_ENVIRONMENT=production - APPFLOWY_DATABASE_URL=${APPFLOWY_DATABASE_URL} - APPFLOWY_REDIS_URI=${APPFLOWY_REDIS_URI} - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL} - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL} - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY} - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY} - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET} - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION} - APPFLOWY_ACCESS_CONTROL=${APPFLOWY_ACCESS_CONTROL} # For the CI testing, we set the database connection to 20. The default value is 40. - APPFLOWY_DATABASE_MAX_CONNECTIONS=20 - AI_SERVER_HOST=${AI_SERVER_HOST} - AI_SERVER_PORT=${AI_SERVER_PORT} - APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL} - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST} - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT} - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME} - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL} - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD} - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY} - APPFLOWY_SEARCH_SERVICE_URL=${APPFLOWY_SEARCH_SERVICE_URL:-http://appflowy_search:4002} - APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS=${APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS:-10} - AI_ENABLED=${AI_ENABLED:-true} # SIGNUP_WHITELIST_ENABLED=true requires GOTRUE_DISABLE_SIGNUP=false on # the gotrue service — the whitelist is enforced by GoTrue's # BeforeUserCreated PG hook, which never runs when signup is globally # disabled. - SIGNUP_WHITELIST_ENABLED=${SIGNUP_WHITELIST_ENABLED:-false} - GUEST_INVITES_REQUIRE_ADMIN_APPROVAL=${GUEST_INVITES_REQUIRE_ADMIN_APPROVAL:-false} build: context: . dockerfile: Dockerfile args: FEATURES: "" PROFILE: ci networks: - shared_network image: appflowyinc/appflowy_cloud:${APPFLOWY_CLOUD_VERSION:-latest} depends_on: gotrue: condition: service_healthy appflowy_search: condition: service_started admin_frontend: restart: on-failure build: context: . dockerfile: ./admin_frontend/Dockerfile image: appflowyinc/admin_frontend:${APPFLOWY_ADMIN_FRONTEND_VERSION:-latest} ports: - 3000:3000 environment: - RUST_LOG=${RUST_LOG:-info} - ADMIN_FRONTEND_REDIS_URL=${ADMIN_FRONTEND_REDIS_URL:-redis://redis:6379} - ADMIN_FRONTEND_GOTRUE_URL=${ADMIN_FRONTEND_GOTRUE_URL:-http://gotrue:9999} - ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=${ADMIN_FRONTEND_APPFLOWY_CLOUD_URL:-http://appflowy_cloud:8000} - ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX:-} networks: - shared_network depends_on: gotrue: condition: service_healthy appflowy_cloud: condition: service_started ai: restart: on-failure image: appflowyinc/appflowy_ai_premium:${APPFLOWY_AI_VERSION:-latest} ports: - "5001:5001" environment: - OPENAI_API_KEY=${AI_OPENAI_API_KEY} - AI_AWS_ACCESS_KEY_ID=${APPFLOWY_S3_ACCESS_KEY} - AI_AWS_SECRET_ACCESS_KEY=${APPFLOWY_S3_SECRET_KEY} - AI_APPFLOWY_BUCKET_NAME=${AI_APPFLOWY_BUCKET_NAME} - AI_SERVER_PORT=${AI_SERVER_PORT} - AI_DATABASE_URL=${AI_DATABASE_URL} - AI_REDIS_URL=${AI_REDIS_URL} - AI_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - AI_MINIO_URL=${AI_MINIO_URL} - AI_APPFLOWY_HOST=${AI_APPFLOWY_HOST} - SUPPORT_OPENAI_V3_IMAGE_MODEL=false - LOG_LEVEL=DEBUG networks: - shared_network appflowy_worker: restart: on-failure image: appflowyinc/appflowy_worker:${APPFLOWY_WORKER_VERSION:-latest} build: context: . dockerfile: ./services/appflowy-worker/Dockerfile ports: - "4001:4001" environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_WORKER_REDIS_URL=${APPFLOWY_WORKER_REDIS_URL:-redis://redis:6379} - APPFLOWY_WORKER_ENVIRONMENT=production - APPFLOWY_WORKER_DATABASE_URL=${APPFLOWY_WORKER_DATABASE_URL} - APPFLOWY_WORKER_DATABASE_NAME=${APPFLOWY_WORKER_DATABASE_NAME} - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL} - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY} - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY} - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET} - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION} - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST} - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT} - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME} - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL} - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD} networks: - shared_network appflowy_search: restart: on-failure image: appflowyinc/appflowy_search:${APPFLOWY_SEARCH_VERSION:-latest} ports: - "4002:4002" environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_SEARCH_HOST=${APPFLOWY_SEARCH_HOST:-[::]} - APPFLOWY_SEARCH_PORT=${APPFLOWY_SEARCH_PORT:-4002} - APPFLOWY_SEARCH_DATABASE_URL=${APPFLOWY_DATABASE_URL} - APPFLOWY_SEARCH_REDIS_URL=${APPFLOWY_REDIS_URI} - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY} - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT} - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION} - APPFLOWY_BACKGROUND_INDEXER_ENABLED=true - APPFLOWY_INDEXER_DATABASE_ENABLED=${APPFLOWY_INDEXER_DATABASE_ENABLED:-false} - APPFLOWY_KEYWORD_SEARCH_ENABLED=${APPFLOWY_KEYWORD_SEARCH_ENABLED:-false} - APPFLOWY_KEYWORD_WORKER_ENABLED=true - APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES=${APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES:-0} - APPFLOWY_KEYWORD_INDEX_DIR=${APPFLOWY_KEYWORD_INDEX_DIR:-/var/lib/appflowy/keyword_index} - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} volumes: - keyword_index_data:/var/lib/appflowy/keyword_index networks: - shared_network depends_on: postgres: condition: service_healthy volumes: postgres_data: minio_data: keyword_index_data: networks: shared_network: name: appflowy_network driver: bridge ================================================ FILE: docker-compose-dev.yml ================================================ services: minio: restart: on-failure image: minio/minio ports: - 9000:9000 - 9001:9001 environment: - MINIO_BROWSER_REDIRECT_URL=http://localhost:9001 command: server /data --console-address ":9001" postgres: restart: on-failure image: pgvector/pgvector:pg15 environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_DB=${POSTGRES_DB:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - POSTGRES_HOST=${POSTGRES_HOST:-postgres} - SUPABASE_PASSWORD=${SUPABASE_PASSWORD:-root} - PGPORT=${POSTGRES_PORT:-5432} command: ["postgres", "-c", "port=${POSTGRES_PORT:-5432}"] ports: - "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}" healthcheck: test: [ "CMD", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "${POSTGRES_DB}", "-p", "${POSTGRES_PORT:-5432}" ] interval: 5s timeout: 5s retries: 12 redis: restart: on-failure image: redis ports: - 6379:6379 gotrue: restart: on-failure image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest} depends_on: postgres: condition: service_healthy environment: # Gotrue config: https://github.com/supabase/auth/blob/master/example.env - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL} - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD} - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false} - GOTRUE_SITE_URL=appflowy-flutter:// # redirected to AppFlowy application - GOTRUE_URI_ALLOW_LIST=** # adjust restrict if necessary - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} # authentication secret - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP} # Without this environment variable, the createuser command will create an admin # with the `admin` role as opposed to `supabase_admin` - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin - GOTRUE_DB_DRIVER=postgres - API_EXTERNAL_URL=${API_EXTERNAL_URL} - DATABASE_URL=${GOTRUE_DATABASE_URL} - PORT=9999 - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/verify - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST} # e.g. smtp.gmail.com - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT} # e.g. 465 - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER} # email sender, e.g. noreply@appflowy.io - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS} # email password - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL} # email with admin privileges e.g. internal@appflowy.io - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns} # set to 1ns for running tests - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false} # change this to true to skip email confirmation # Google OAuth config - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED} - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID} - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET} - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI} # Apple OAuth config - GOTRUE_EXTERNAL_APPLE_ENABLED=${GOTRUE_EXTERNAL_APPLE_ENABLED} - GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${GOTRUE_EXTERNAL_APPLE_CLIENT_ID} - GOTRUE_EXTERNAL_APPLE_SECRET=${GOTRUE_EXTERNAL_APPLE_SECRET} - GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=${GOTRUE_EXTERNAL_APPLE_REDIRECT_URI} # GITHUB OAuth config - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED} - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID} - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET} - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI} # Discord OAuth config - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED} - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID} - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET} - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI} - GOTRUE_MAILER_TEMPLATES_CONFIRMATION=${GOTRUE_MAILER_TEMPLATES_CONFIRMATION} ports: - 9999:9999 pgadmin: restart: on-failure image: dpage/pgadmin4 depends_on: - postgres environment: - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL} - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD} ports: - 5400:80 volumes: - ./docker/pgadmin/servers.json:/pgadmin4/servers.json volumes: postgres_data: ================================================ FILE: docker-compose-extras.yml ================================================ # Non-essential additional services include: - docker-compose.yml services: tunnel: image: cloudflare/cloudflared restart: unless-stopped command: tunnel --no-autoupdate run environment: - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} pgadmin: restart: on-failure image: dpage/pgadmin4 environment: - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL} - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD} volumes: - ./docker/pgadmin/servers.json:/pgadmin4/servers.json ================================================ FILE: docker-compose.yml ================================================ # Essential services for AppFlowy Cloud services: nginx: restart: on-failure image: nginx ports: - ${NGINX_PORT:-80}:80 # Disable this if you are using TLS - ${NGINX_TLS_PORT:-443}:443 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./nginx/ssl/certificate.crt:/etc/nginx/ssl/certificate.crt - ./nginx/ssl/private_key.key:/etc/nginx/ssl/private_key.key # You do not need this if you have configured to use your own s3 file storage minio: restart: on-failure image: minio/minio environment: - MINIO_BROWSER_REDIRECT_URL=${APPFLOWY_BASE_URL?:err}/minio - MINIO_ROOT_USER=${APPFLOWY_S3_ACCESS_KEY:-minioadmin} - MINIO_ROOT_PASSWORD=${APPFLOWY_S3_SECRET_KEY:-minioadmin} command: server /data --console-address ":9001" healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] interval: 30s timeout: 20s retries: 3 volumes: - minio_data:/data postgres: restart: on-failure image: pgvector/pgvector:pg16 environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_DB=${POSTGRES_DB:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - POSTGRES_HOST=${POSTGRES_HOST:-postgres} - PGPORT=${POSTGRES_PORT:-5432} command: [ "postgres", "-c", "port=${POSTGRES_PORT:-5432}" ] healthcheck: test: [ "CMD", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "${POSTGRES_DB}", "-p", "${POSTGRES_PORT:-5432}" ] interval: 5s timeout: 5s retries: 12 volumes: - postgres_data:/var/lib/postgresql/data redis: restart: on-failure image: redis gotrue: restart: on-failure depends_on: postgres: condition: service_healthy healthcheck: test: "curl --fail http://127.0.0.1:9999/health || exit 1" interval: 5s timeout: 5s retries: 12 start_period: 40s image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest} environment: # There are a lot of options to configure GoTrue. You can reference the example config: # https://github.com/supabase/auth/blob/master/example.env # The initial GoTrue Admin user to create, if not already exists. - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL} # The initial GoTrue Admin user password to create, if not already exists. # If the user already exists, the update will be skipped. - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD} - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false} - GOTRUE_SITE_URL=appflowy-flutter:// # redirected to AppFlowy application - GOTRUE_URI_ALLOW_LIST=** # adjust restrict if necessary - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} # authentication secret - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP} # Without this environment variable, the createuser command will create an admin # with the `admin` role as opposed to `supabase_admin` - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin - GOTRUE_DB_DRIVER=postgres - API_EXTERNAL_URL=${API_EXTERNAL_URL} - DATABASE_URL=${GOTRUE_DATABASE_URL} - PORT=9999 - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST} # e.g. smtp.gmail.com - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT} # e.g. 465 - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER} # email sender, e.g. noreply@appflowy.io - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS} # email password - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/gotrue/verify - GOTRUE_MAILER_URLPATHS_INVITE=/gotrue/verify - GOTRUE_MAILER_URLPATHS_RECOVERY=/gotrue/verify - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/gotrue/verify - GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=${GOTRUE_MAILER_TEMPLATES_MAGIC_LINK} - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL} # email with admin privileges e.g. internal@appflowy.io - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns} # set to 1ns for running tests - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false} # change this to true to skip email confirmation # Google OAuth config - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED} - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID} - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET} - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI} # GITHUB OAuth config - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED} - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID} - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET} - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI} # Discord OAuth config - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED} - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID} - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET} - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI} # SAML 2.0 OAuth config - GOTRUE_SAML_ENABLED=${GOTRUE_SAML_ENABLED} - GOTRUE_SAML_PRIVATE_KEY=${GOTRUE_SAML_PRIVATE_KEY} appflowy_cloud: restart: on-failure environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_ENVIRONMENT=production - APPFLOWY_DATABASE_URL=${APPFLOWY_DATABASE_URL} - APPFLOWY_REDIS_URI=${APPFLOWY_REDIS_URI} - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL} - APPFLOWY_S3_CREATE_BUCKET=${APPFLOWY_S3_CREATE_BUCKET} - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL} - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY} - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY} - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET} - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION} - APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=${APPFLOWY_S3_PRESIGNED_URL_ENDPOINT} - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST} - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT} - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME} - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL} - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD} - APPFLOWY_MAILER_SMTP_TLS_KIND=${APPFLOWY_MAILER_SMTP_TLS_KIND} - APPFLOWY_ACCESS_CONTROL=${APPFLOWY_ACCESS_CONTROL} - APPFLOWY_DATABASE_MAX_CONNECTIONS=${APPFLOWY_DATABASE_MAX_CONNECTIONS} - AI_SERVER_HOST=${AI_SERVER_HOST} - AI_SERVER_PORT=${AI_SERVER_PORT} - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY} - APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL} - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL} - ASSEMBLYAI_API_KEY=${ASSEMBLYAI_API_KEY} - ASSEMBLYAI_API_BASE=${ASSEMBLYAI_API_BASE} - ASSEMBLYAI_STREAMING_API_BASE=${ASSEMBLYAI_STREAMING_API_BASE} - APPFLOWY_SEARCH_SERVICE_URL=${APPFLOWY_SEARCH_SERVICE_URL:-http://appflowy_search:4002} - APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS=${APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS:-10} - AI_ENABLED=${AI_ENABLED:-true} # SIGNUP_WHITELIST_ENABLED=true requires GOTRUE_DISABLE_SIGNUP=false on # the gotrue service. The whitelist is enforced by GoTrue's # BeforeUserCreated PG hook, which never runs when signup is globally # disabled — leaving the whitelist a no-op. Set GOTRUE_DISABLE_SIGNUP=false # and rely on the whitelist + invitations to gate who can sign up. - SIGNUP_WHITELIST_ENABLED=${SIGNUP_WHITELIST_ENABLED:-false} - GUEST_INVITES_REQUIRE_ADMIN_APPROVAL=${GUEST_INVITES_REQUIRE_ADMIN_APPROVAL:-false} image: appflowyinc/appflowy_cloud:${APPFLOWY_CLOUD_VERSION:-latest} healthcheck: test: "curl --fail http://127.0.0.1:8000/api/health || exit 1" interval: 5s timeout: 5s retries: 12 depends_on: gotrue: condition: service_healthy admin_frontend: restart: on-failure image: appflowyinc/admin_frontend:${APPFLOWY_ADMIN_FRONTEND_VERSION:-latest} environment: - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL:-http://gotrue:9999} - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL:-http://appflowy_cloud:8000} depends_on: gotrue: condition: service_healthy appflowy_cloud: condition: service_healthy ai: restart: on-failure image: appflowyinc/appflowy_ai:${APPFLOWY_AI_VERSION:-latest} environment: - AI_SERVER_PORT=${AI_SERVER_PORT} - OPENAI_API_KEY=${AI_OPENAI_API_KEY} - DEFAULT_AI_MODEL=gpt-4.1-mini # Make sure the model is available in your OpenAI account - DEFAULT_AI_COMPLETION_MODEL=gpt-4.1-mini # Make sure the model is available in your OpenAI account - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT} - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION} - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY} - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY} - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET} - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION} - AI_DATABASE_URL=${APPFLOWY_DATABASE_URL} - AI_REDIS_URL=${APPFLOWY_REDIS_URI} - AI_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - AI_MINIO_URL=${APPFLOWY_S3_MINIO_URL} - AI_APPFLOWY_HOST=${APPFLOWY_BASE_URL} - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:5001/health" ] interval: 30s timeout: 10s retries: 3 start_period: 40s depends_on: postgres: condition: service_healthy appflowy_cloud: condition: service_healthy appflowy_worker: restart: on-failure image: appflowyinc/appflowy_worker:${APPFLOWY_WORKER_VERSION:-latest} environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_ENVIRONMENT=production - APPFLOWY_WORKER_REDIS_URL=${APPFLOWY_WORKER_REDIS_URL:-redis://redis:6379} - APPFLOWY_WORKER_ENVIRONMENT=production - APPFLOWY_WORKER_DATABASE_URL=${APPFLOWY_WORKER_DATABASE_URL} - APPFLOWY_WORKER_DATABASE_NAME=${APPFLOWY_WORKER_DATABASE_NAME} - APPFLOWY_WORKER_IMPORT_TICK_INTERVAL=30 - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO} - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL} - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY} - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY} - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET} - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION} - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST} - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT} - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME} - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL} - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD} - APPFLOWY_MAILER_SMTP_TLS_KIND=${APPFLOWY_MAILER_SMTP_TLS_KIND} depends_on: postgres: condition: service_healthy appflowy_cloud: condition: service_healthy appflowy_search: restart: on-failure image: appflowyinc/appflowy_search:${APPFLOWY_SEARCH_VERSION:-latest} environment: - RUST_LOG=${RUST_LOG:-info} - APPFLOWY_SEARCH_HOST=${APPFLOWY_SEARCH_HOST:-[::]} - APPFLOWY_SEARCH_PORT=${APPFLOWY_SEARCH_PORT:-4002} - APPFLOWY_SEARCH_DATABASE_URL=${APPFLOWY_DATABASE_URL} - APPFLOWY_SEARCH_REDIS_URL=${APPFLOWY_REDIS_URI} - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY} - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT} - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION} - APPFLOWY_BACKGROUND_INDEXER_ENABLED=true - APPFLOWY_INDEXER_DATABASE_ENABLED=${APPFLOWY_INDEXER_DATABASE_ENABLED:-false} - APPFLOWY_KEYWORD_SEARCH_ENABLED=${APPFLOWY_KEYWORD_SEARCH_ENABLED:-true} - APPFLOWY_KEYWORD_WORKER_ENABLED=true - APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES=${APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES:-2147483648} - APPFLOWY_KEYWORD_INDEX_DIR=${APPFLOWY_KEYWORD_INDEX_DIR:-/var/lib/appflowy/keyword_index} - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} volumes: - keyword_index_data:/var/lib/appflowy/keyword_index depends_on: postgres: condition: service_healthy appflowy_web: restart: on-failure image: appflowyinc/appflowy_web:${APPFLOWY_WEB_VERSION:-latest} depends_on: appflowy_cloud: condition: service_healthy environment: - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL} - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_BASE_URL}/gotrue - APPFLOWY_WS_BASE_URL=${APPFLOWY_WEBSOCKET_BASE_URL} volumes: postgres_data: minio_data: keyword_index_data: ================================================ FILE: email_template/.editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: email_template/.gitignore ================================================ node_modules build_local .vscode .idea Thumbs.db .DS_Store npm-debug.log yarn-error.log ================================================ FILE: email_template/.npmrc ================================================ shamefully-hoist=true ================================================ FILE: email_template/LICENSE ================================================ The MIT License (MIT) Copyright (c) Cosmin Popovici 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. ================================================ FILE: email_template/README.md ================================================ ## About This contains the source code for the mail templates used by GoTrue and AppFlowy Cloud services. ## Development Run this command and follow the prompts ```bash # install pnpm@8.5.0 npm install -g pnpm@8.5.0 pnpm i pnpm run dev ``` ## Build Run this command to build the project to generate the final output in the assets/mailer_templates/build_production folder ```bash pnpm run build ``` ================================================ FILE: email_template/config.js ================================================ /** @type {import('@maizzle/framework').Config} */ /* |------------------------------------------------------------------------------- | Development config https://maizzle.com/docs/environments |------------------------------------------------------------------------------- | | The exported object contains the default Maizzle settings for development. | This is used when you run `maizzle build` or `maizzle serve` and it has | the fastest build time, since most transformations are disabled. | */ module.exports = { build: { templates: { source: "src/templates", destination: { path: "build_local", }, assets: { source: "src/images", destination: "images", }, }, }, locals: { cdnBaseUrl: "", userIconUrl: "https://cdn-icons-png.flaticon.com/512/1077/1077012.png", error: "Test error message", detailError: "Test detail error message", userName: "John Doe", acceptUrl: "https://appflowy.io", approveUrl: "https://appflowy.io", launchWorkspaceUrl: "https://appflowy.io", workspaceName: "AppFlowy", workspaceMembersCount: "100", workspaceIconURL: "https://cdn-icons-png.flaticon.com/512/1078/1078013.png", mentionedPageName: "Test Page", mentionedPageUrl: "https://appflowy.io", mentionerName: "John Doe", mentionerIconUrl: "https://cdn-icons-png.flaticon.com/512/1077/1077012.png", mentionedAt: "Jul 22, 2025, 3:42 PM (UTC)", }, }; ================================================ FILE: email_template/config.production.js ================================================ /** @type {import('@maizzle/framework').Config} */ /* |------------------------------------------------------------------------------- | Production config https://maizzle.com/docs/environments |------------------------------------------------------------------------------- | | This is where you define settings that optimize your emails for production. | These will be merged on top of the base config.js, so you only need to | specify the options that are changing. | */ module.exports = { build: { templates: { destination: { path: "../assets/mailer_templates/build_production", }, }, }, locals: { cdnBaseUrl: "https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/", error: "{{ error }}", detailError: "{{ error_detail }}", userIconUrl: "{{ user_icon_url }}", importFileName: "{{ import_file_name }}", importTaskId: "{{ import_task_id }}", userName: "{{ username }}", acceptUrl: "{{ accept_url }}", approveUrl: "{{ approve_url }}", launchWorkspaceUrl: "{{ launch_workspace_url }}", workspaceName: "{{ workspace_name }}", workspaceMembersCount: "{{ workspace_member_count }}", workspaceIconURL: "{{ workspace_icon_url }}", mentionedPageName: "{{ mentioned_page_name }}", mentionedPageUrl: "{{ mentioned_page_url }}", mentionerName: "{{ mentioner_name }}", mentionerIconUrl: "{{ mentioner_icon_url }}", mentionedAt: "{{ mentioned_at }}", }, inlineCSS: true, removeUnusedCSS: true, shorthandCSS: true, prettify: true, }; ================================================ FILE: email_template/package.json ================================================ { "private": true, "scripts": { "dev": "maizzle serve", "build": "maizzle build production" }, "dependencies": { "@maizzle/framework": "4.4.6", "tailwindcss-box-shadow": "^2.0.0", "tailwindcss-email-variants": "^2.0.0", "tailwindcss-mso": "^1.4.1", "tailwindcss": "^3.3.0" }, "engines": { "pnpm": ">=8.0.0 <9.0.0", "node": ">=14.0.0" } } ================================================ FILE: email_template/src/components/button.html ================================================ ================================================ FILE: email_template/src/components/divider.html ================================================ ================================================ FILE: email_template/src/components/footer.html ================================================
Bring projects, knowledge, and teams together with the power of AI.
Twitter Reddit GitHub Discord
Copyright © 2025, AppFlowy Inc.
Need Help? support@appflowy.io
================================================ FILE: email_template/src/components/spacer.html ================================================
================================================ FILE: email_template/src/components/v-fill.html ================================================ ================================================ FILE: email_template/src/components/v-image.html ================================================ ================================================ FILE: email_template/src/css/resets.css ================================================ /* * Here is where you can add your global email CSS resets. * * We use a custom, email-specific CSS reset, instead * of Tailwind's web-optimized `base` layer. * * Styles defined here will be inlined. */ img { @apply max-w-full leading-none align-middle; } ================================================ FILE: email_template/src/css/tailwind.css ================================================ /* Your custom CSS resets for email */ @import "resets"; /* Tailwind CSS components */ @import "tailwindcss/components"; /** * @import here any custom CSS components - that is, CSS that * you'd want loaded before the Tailwind utilities, so the * utilities can still override them. */ /* Tailwind CSS utility classes */ @import "tailwindcss/utilities"; /* Your custom utility classes */ @import "utilities"; ================================================ FILE: email_template/src/css/utilities.css ================================================ /* * Here is where you can define your custom utility classes. * * We wrap them in the `utilities` @layer directive, so * that Tailwind moves them to the correct location. * * More info: * https://tailwindcss.com/docs/functions-and-directives#layer */ @layer utilities { .break-word { word-break: break-word; } } ================================================ FILE: email_template/src/layouts/main.html ================================================ {{{ page.title }}}
================================================ FILE: email_template/src/templates/access_request.html ================================================ --- title: "Request to join the workspace" preheader: "Approve a user's request to join the workspace." bodyClass: bg-purple-50 ---
{{ userName }}

{{ userName }} has requested access to {{ workspaceName }}

{{ workspaceName }}
{{ workspaceName }}
{{ workspaceMembersCount }} members
Approve request
By clicking "Approve request" above, the user will be added to the workspace.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: email_template/src/templates/access_request_approved_notification.html ================================================ --- title: "Your access request has been approved" preheader: "Workspace access request approved notification" bodyClass: bg-purple-50 ---

Your request to access {{ workspaceName }} has been approved

{{ workspaceName }}
{{ workspaceName }}
{{ workspaceMembersCount }} members
View workspace
By clicking "View workspace" above, you confirm that you have read, understood, and agreed to AppFlowy's Terms & Conditions and Privacy Policy.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: email_template/src/templates/confirmation.html ================================================ --- title: "New sign up for AppFlowy" bodyClass: #EEEEFC ---
To login to AppFlowy, follow this link @{{ .ConfirmationURL }}

Login for AppFlowy

We have received a request to confirm your AppFlowy account.

You can log in using either of the following options:

Option 1: Magic Link (Fast & Easy)

Click the button or link below to log in instantly

Login to AppFlowy

Or paste this into your browser:

@{{ .ConfirmationURL }}

Option 2: One-Time Password (OTP)

Prefer to enter a code instead? Use the one-time code below

@{{ .Token }}

This code and magic link will expire in 5 minutes for security reasons.

If you didn't initiate this login, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: email_template/src/templates/import_data_fail.html ================================================ --- title: "Workspace Import Failed" preheader: "There was an issue with your workspace import" bodyClass: bg-purple-50 ---

Notion Import Failed

{{ error }}

Join our Discord server to get quick help or report the issue on GitHub

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: email_template/src/templates/import_data_success.html ================================================ --- title: "Workspace Import Success" preheader: "Your workspace import was successful" bodyClass: bg-purple-50 ---

Notion Import Complete

Your Notion data has been successfully imported into

{{ workspaceName }}

{{ workspaceName }}
{{ workspaceName }}
1 member
Download

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: email_template/src/templates/magic_link.html ================================================ --- title: "Login for AppFlowy" bodyClass: #EEEEFC ---
To login to AppFlowy, follow this link @{{ .ConfirmationURL }}

Login for AppFlowy

We received a request to log in to your AppFlowy account.

You can log in using either of the following options:

Option 1: Magic Link (Fast & Easy)

Click the button or link below to log in instantly

Login to AppFlowy

Or paste this into your browser:

@{{ .ConfirmationURL }}

Option 2: One-Time Password (OTP)

Prefer to enter a code instead? Use the one-time code below

@{{ .Token }}

This code and magic link will expire in 5 minutes for security reasons.

If you didn't initiate this login, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: email_template/src/templates/page_mention_notification.html ================================================ --- bodyClass: bg-[#EEEEFC] ---

AppFlowy

{{ mentionerName }} has mentioned you in {{ mentionedPageName }}

{{ mentionedAt }}

{{ workspaceName }} / ... / {{ mentionedPageName }}

Go to page

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter YouTube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

{{ timestamp }}
================================================ FILE: email_template/src/templates/recovery.html ================================================ --- title: "AppFlowy Password Recovery" bodyClass: #EEEEFC ---
To reset your password, enter the code below in the app:

Reset your password

Someone recently requested a password reset for your AppFlowy account. If this was you, use the following verification code.

@{{ .Token }}

This code will expire in 5 minutes for security reasons.

If you didn't initiate this recovery, you can safely ignore this email. No action is needed.

Bring projects, knowledge, and teams together with the power of AI.

Discord GitHub Reddit Twitter Youtube

Copyright © 2025, AppFlowy Inc.

Need Help? support@appflowy.io

================================================ FILE: email_template/src/templates/workspace_invitation.html ================================================ --- title: "Confirm to join the workspace" preheader: "Please confirm your email address to join the workspace." bodyClass: bg-purple-50 ---
{{ userName }}

{{ userName }} invited you to {{ workspaceName }}

{{ workspaceName }}
{{ workspaceName }}
{{ workspaceMembersCount }} members
Join workspace
require v0.5.6+ to continue
By clicking "Join workspace" above, you confirm that you have read, understood, and agreed to AppFlowy's Terms & Conditions and Privacy Policy.

Bring projects, knowledge, and teams together with the power of AI.

Maizzle Maizzle Maizzle Maizzle

================================================ FILE: email_template/tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ module.exports = { theme: { screens: { sm: { max: '600px' }, xs: { max: '425px' }, }, extend: { spacing: { screen: '100vw', full: '100%', 0: '0', 0.5: '2px', 1: '4px', 1.5: '6px', 2: '8px', 2.5: '10px', 3: '12px', 3.5: '14px', 4: '16px', 4.5: '18px', 5: '20px', 5.5: '22px', 6: '24px', 6.5: '26px', 7: '28px', 7.5: '30px', 8: '32px', 8.5: '34px', 9: '36px', 9.5: '38px', 10: '40px', 11: '44px', 12: '48px', 14: '56px', 16: '64px', 20: '80px', 24: '96px', 28: '112px', 32: '128px', 36: '144px', 40: '160px', 44: '176px', 48: '192px', 52: '208px', 56: '224px', 60: '240px', 64: '256px', 72: '288px', 80: '320px', 96: '384px', }, borderRadius: { none: '0px', sm: '2px', DEFAULT: '4px', md: '6px', lg: '8px', xl: '12px', '2xl': '16px', '3xl': '24px', }, boxShadow: { sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)', md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)', }, fontFamily: { sans: ['ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'], helvetica: ['Helvetica', 'ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], }, fontSize: { 0: '0', xxs: '11px', xs: '12px', '2xs': '13px', sm: '14px', '2sm': '15px', base: '16px', lg: '18px', xl: '20px', '2xl': '24px', '3xl': '30px', '4xl': '36px', '5xl': '48px', '6xl': '60px', '7xl': '72px', '8xl': '96px', '9xl': '128px', }, letterSpacing: theme => ({ ...theme('width'), }), lineHeight: theme => ({ ...theme('width'), }), maxWidth: theme => ({ ...theme('width'), xs: '160px', sm: '192px', md: '224px', lg: '256px', xl: '288px', '2xl': '336px', '3xl': '384px', '4xl': '448px', '5xl': '512px', '6xl': '576px', '7xl': '640px', }), minHeight: theme => ({ ...theme('width'), }), minWidth: theme => ({ ...theme('width'), }), }, }, corePlugins: { preflight: false, backgroundOpacity: false, borderOpacity: false, divideOpacity: false, placeholderOpacity: false, textOpacity: false, }, plugins: [ require('tailwindcss-mso'), require('tailwindcss-box-shadow'), require('tailwindcss-email-variants'), ], }; ================================================ FILE: env.deploy.secret.example ================================================ # Copy this file to .env.deploy.secret and replace the placeholder values with # your actual production secrets. Use this with deploy.env as your base environment. # # Usage: # cp env.deploy.secret.example .env.deploy.secret # # Edit .env.deploy.secret with your real production values # ./script/generate_env.sh # # Select "deploy.env" when prompted # # OAuth Providers (optional) GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID= GOTRUE_EXTERNAL_GOOGLE_SECRET= # AI Features (optional) AI_OPENAI_API_KEY= ================================================ FILE: env.dev.secret.example ================================================ # Copy this file to .env.dev.secret and replace the placeholder values with your # actual secrets. The generate_env.sh script will merge these values into your # chosen base environment file (deploy.env or dev.env). # # Usage: # cp env.dev.secret.example .env.dev.secret # # Edit .env.dev.secret with your real values # ./script/generate_env.sh # # Select "dev.env" when prompted # OAuth Providers (optional) GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID= GOTRUE_EXTERNAL_GOOGLE_SECRET= # AI Features (optional) AI_OPENAI_API_KEY= ================================================ FILE: external_proxy_config/nginx/appflowy.site.conf ================================================ # You can use this site configuration as a template if you want to use an external reverse proxy # as opposed to the nginx service in the docker compose. # Remember to expose the ports of the docker compose services based on the configuration here, and # update the Nginx configuration as necessary if you map the services to different ports. map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { server_name appflowy-cloud.example.com; listen 80; underscores_in_headers on; set $appflowy_cloud_backend "http://127.0.0.1:8000"; set $gotrue_backend "http://127.0.0.1:9999"; set $admin_frontend_backend "http://127.0.0.1:3001"; set $appflowy_web_backend "http://127.0.0.1:3000"; set $minio_backend "http://127.0.0.1:9001"; set $minio_api_backend "http://127.0.0.1:9000"; # Host name for minio, used internally within docker compose set $minio_internal_host "minio:9000"; # GoTrue location /gotrue/ { proxy_pass $gotrue_backend; rewrite ^/gotrue(/.*)$ $1 break; # Allow headers like redirect_to to be handed over to the gotrue # for correct redirecting proxy_set_header Host $http_host; proxy_pass_request_headers on; } # WebSocket location /ws { proxy_pass $appflowy_cloud_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400s; } location /api { proxy_pass $appflowy_cloud_backend; proxy_set_header X-Request-Id $request_id; proxy_set_header Host $http_host; location ~* ^/api/workspace/([a-zA-Z0-9_-]+)/publish$ { proxy_pass $appflowy_cloud_backend; proxy_request_buffering off; client_max_body_size 256M; } # AppFlowy-Cloud location /api/chat { proxy_pass $appflowy_cloud_backend; proxy_http_version 1.1; proxy_set_header Connection ""; chunked_transfer_encoding on; proxy_buffering off; proxy_cache off; proxy_read_timeout 600s; proxy_connect_timeout 600s; proxy_send_timeout 600s; } location /api/import { proxy_pass $appflowy_cloud_backend; # Set headers proxy_set_header X-Request-Id $request_id; proxy_set_header Host $http_host; proxy_set_header X-Host $scheme://$host; # Timeouts proxy_read_timeout 600s; proxy_connect_timeout 600s; proxy_send_timeout 600s; # Disable buffering for large file uploads proxy_request_buffering off; proxy_buffering off; proxy_cache off; client_max_body_size 2G; } } # Minio Web UI # Derive from: https://min.io/docs/minio/linux/integrations/setup-nginx-proxy-with-minio.html # Optional Module, comment this section if you are did not deploy minio in docker-compose.yml # This endpoint is meant to be used for the MinIO Web UI, accessible via the admin portal location /minio/ { proxy_pass $minio_backend; rewrite ^/minio/(.*) /$1 break; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-NginX-Proxy true; ## This is necessary to pass the correct IP to be hashed real_ip_header X-Real-IP; proxy_connect_timeout 300s; ## To support websockets in MinIO versions released after January 2023 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Some environments may encounter CORS errors (Kubernetes + Nginx Ingress) # Uncomment the following line to set the Origin request to an empty string # proxy_set_header Origin ''; chunked_transfer_encoding off; } # Optional Module, comment this section if you are did not deploy minio in docker-compose.yml # This is used for presigned url, which is needs to be exposed to the AppFlowy client application. location /minio-api/ { proxy_pass $minio_api_backend; # Set the host to internal host because the presigned url was signed against the internal host proxy_set_header Host $minio_internal_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; rewrite ^/minio-api/(.*) /$1 break; proxy_connect_timeout 300s; # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 proxy_http_version 1.1; proxy_set_header Connection ""; chunked_transfer_encoding off; client_max_body_size 1000M; } # Admin Frontend # Optional Module, comment this section if you are did not deploy admin_frontend in docker-compose.yml location /console { proxy_pass $admin_frontend_backend; proxy_set_header X-Scheme $scheme; proxy_set_header Host $host; } # AppFlowy Web location / { proxy_pass $appflowy_web_backend; proxy_set_header X-Scheme $scheme; proxy_set_header Host $host; } } ================================================ FILE: libs/access-control/Cargo.toml ================================================ [package] name = "access-control" version = "0.1.0" edition = "2021" [dependencies] actix-http.workspace = true app-error.workspace = true anyhow.workspace = true async-trait.workspace = true casbin = { version = "2.10.1", features = [ "cached", "runtime-tokio", "incremental", ], optional = true } database.workspace = true database-entity.workspace = true futures-util.workspace = true lazy_static.workspace = true prometheus-client.workspace = true rand = "0.8" redis = { workspace = true, features = [ "aio", "tokio-comp", "connection-manager", ] } serde_json = { version = "1.0" } sqlx = { workspace = true, default-features = false, features = ["postgres"] } tracing.workspace = true tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } tokio-stream.workspace = true uuid = { workspace = true, features = ["v4"] } serde = { version = "1.0.200", features = ["derive"] } infra.workspace = true [features] default = ["casbin"] casbin = ["dep:casbin"] ================================================ FILE: libs/access-control/src/act.rs ================================================ use actix_http::Method; use database_entity::dto::{AFAccessLevel, AFRole}; use std::cmp::Ordering; /// Defines behavior for objects that can translate to a set of action identifiers. /// pub trait Acts { fn policy_acts(&self) -> Vec { vec![self.to_enforce_act()] } fn to_enforce_act(&self) -> String; fn from_enforce_act(act: &str) -> Self; } impl Acts for AFAccessLevel { /// maps different access levels to a set of predefined action /// identifiers that represent permissions. It starts with a base action applicable /// to all access levels and extends this set based on the access level: /// /// - `ReadOnly`: Only includes the base action (`"10"`), indicating minimum permissions. /// - `ReadAndComment`: Adds `"20"` to the base action, allowing reading and commenting. /// - `ReadAndWrite`: Extends permissions to include `"20"` and `"30"`, enabling reading, commenting, and writing. /// - `FullAccess`: Grants all possible actions by including `"20"`, `"30"`, and `"50"`, representing the highest level of access. /// fn to_enforce_act(&self) -> String { match self { AFAccessLevel::ReadOnly => "l:10".to_string(), AFAccessLevel::ReadAndComment => "l:20".to_string(), AFAccessLevel::ReadAndWrite => "l:30".to_string(), AFAccessLevel::FullAccess => "l:50".to_string(), } } fn from_enforce_act(act: &str) -> Self { match act { "l:10" => AFAccessLevel::ReadOnly, "l:20" => AFAccessLevel::ReadAndComment, "l:30" => AFAccessLevel::ReadAndWrite, "l:50" => AFAccessLevel::FullAccess, _ => AFAccessLevel::ReadOnly, } } } impl Acts for AFRole { /// Returns a vector of action identifiers associated with a user's role. /// /// The function maps each role to a set of actions, reflecting a permission hierarchy /// where higher roles inherit the permissions of the lower ones. The roles are defined /// as follows: /// - `Owner`: Has the highest level of access, including all possible actions (`"1"`, `"2"`, and `"3"`). /// - `Member`: Can perform a subset of actions allowed for owners, excluding the most privileged ones (`"2"` and `"3"`). /// - `Guest`: Has the least level of access, limited to the least privileged actions (`"3"` only). /// fn to_enforce_act(&self) -> String { match self { AFRole::Owner => "r:1".to_string(), AFRole::Member => "r:2".to_string(), AFRole::Guest => "r:3".to_string(), } } fn from_enforce_act(act: &str) -> Self { match act { "r:1" => AFRole::Owner, "r:2" => AFRole::Member, "r:3" => AFRole::Guest, _ => AFRole::Guest, } } } /// Represents the actions that can be performed on objects. #[derive(Debug, Clone, Eq, PartialEq)] pub enum Action { Read, Write, Delete, } impl PartialOrd for Action { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Action { fn cmp(&self, other: &Self) -> Ordering { match (self, other) { // Read (Action::Read, Action::Read) => Ordering::Equal, (Action::Read, _) => Ordering::Less, (_, Action::Read) => Ordering::Greater, // Write (Action::Write, Action::Write) => Ordering::Equal, (Action::Write, Action::Delete) => Ordering::Less, // Delete (Action::Delete, Action::Write) => Ordering::Greater, (Action::Delete, Action::Delete) => Ordering::Equal, } } } impl Acts for Action { fn to_enforce_act(&self) -> String { match self { Action::Read => "read".to_string(), Action::Write => "write".to_string(), Action::Delete => "delete".to_string(), } } fn from_enforce_act(act: &str) -> Self { match act { "read" => Action::Read, "write" => Action::Write, "delete" => Action::Delete, _ => Action::Read, } } } impl From<&Method> for Action { fn from(method: &Method) -> Self { match *method { Method::POST => Action::Write, Method::PUT => Action::Write, Method::DELETE => Action::Delete, _ => Action::Read, } } } ================================================ FILE: libs/access-control/src/casbin/access.rs ================================================ use super::adapter::PgAdapter; use crate::act::{Action, Acts}; use crate::entity::{ObjectType, SubjectType}; use crate::metrics::{tick_metric, AccessControlMetrics}; use anyhow::anyhow; use app_error::AppError; use casbin::function_map::OperatorFunction; use casbin::rhai::{Dynamic, ImmutableString}; use casbin::{CachedEnforcer, CoreApi, DefaultModel, MgmtApi}; use database_entity::dto::{AFAccessLevel, AFRole}; use sqlx::PgPool; use crate::casbin::enforcer_v2::{AFEnforcerV2, ConsistencyMode}; use std::sync::Arc; use tracing::trace; /// Manages access control. /// /// Stores access control policies in the form `subject, object, role` /// where `subject` is `uid`, `object` is `oid`, and `role` is [AFAccessLevel] or [AFRole]. /// /// Roles are mapped to the corresponding actions that they are allowed to perform. /// `FullAccess` has write /// `FullAccess` has read /// /// Access control requests are made in the form `subject, object, action` /// and will be evaluated against the policies and mappings stored, /// according to the model defined. #[derive(Clone)] pub struct AccessControl { enforcer: Arc, #[allow(dead_code)] access_control_metrics: Arc, } impl AccessControl { pub async fn new( pg_pool: PgPool, redis_uri: Option<&str>, access_control_metrics: Arc, ) -> Result { let model = casbin_model().await?; let adapter = PgAdapter::new(pg_pool.clone(), access_control_metrics.clone()); let mut enforcer = casbin::CachedEnforcer::new(model, adapter) .await .map_err(|e| { AppError::Internal(anyhow!("Failed to create access control enforcer: {}", e)) })?; enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); let enforcer = match redis_uri { None => AFEnforcerV2::new(enforcer).await?, Some(redis_uri) => AFEnforcerV2::new_with_redis(enforcer, redis_uri).await?, }; tick_metric( enforcer.metrics_state.clone(), access_control_metrics.clone(), ); Ok(Self { enforcer: Arc::new(enforcer), access_control_metrics, }) } #[cfg(test)] pub fn with_enforcer(enforcer: AFEnforcerV2) -> Self { let access_control_metrics = Arc::new(AccessControlMetrics::init()); Self { enforcer: Arc::new(enforcer), access_control_metrics, } } pub async fn update_policy( &self, sub: SubjectType, obj: ObjectType, act: T, ) -> Result<(), AppError> where T: Acts, { self.enforcer.update_policy(sub, obj, act).await?; Ok(()) } pub async fn remove_policy(&self, sub: SubjectType, obj: ObjectType) -> Result<(), AppError> { self.enforcer.remove_policy(sub, obj).await?; Ok(()) } /// Enforces access control policy with eventual consistency. /// /// This method provides fast policy checks by evaluating against the current state /// without waiting for pending policy updates to be applied. Use this when: /// - Performance is critical /// - Slight inconsistency is acceptable /// - You need immediate responses pub async fn enforce_immediately( &self, uid: &i64, obj: ObjectType, act: T, ) -> Result where T: Acts, { self.enforcer.enforce_policy(uid, obj, act).await } /// Enforces access control policy with strong consistency. /// /// This method ensures all pending policy updates are applied before evaluation, /// guaranteeing the most up-to-date permissions check. Use this when: /// - Consistency is critical (e.g., security-sensitive operations) /// - After policy changes that must be immediately reflected /// - You can afford to wait for pending updates pub async fn enforce_strong( &self, uid: &i64, obj: ObjectType, act: T, ) -> Result where T: Acts, { self .enforcer .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Strong) .await } pub async fn enforce_weak(&self, uid: &i64, obj: ObjectType, act: T) -> Result where T: Acts, { self .enforcer .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Eventual) .await } } /// /// ## Policy Definitions: /// - p1 = sub=uid, obj=object_id, act=role_id /// - Associates a user (`uid`) with a role (`role_id`) for accessing an object (`object_id`). /// /// - p2 = sub=uid, obj=object_id, act=access_level /// - Specifies the access level (`access_level`) a user (`uid`) has for an object (`object_id`). /// /// - p3 = sub=guid, obj=object_id, act=access_level /// - Defines the access level (`access_level`) a group (`guid`) has for an object (`object_id`). /// /// ## Role Definitions in Database: /// Roles and access levels are defined with the following mappings: /// - **Role "1" (Owner):** Can `delete`, `write`, and `read`. /// - **Role "2" (Member):** Can `write` and `read`. /// - **Role "3" (Guest):** Can `write` and `read`. /// /// ## Access Levels: /// - **"10" (Read-only):** Permission to `read`. /// - **"20" (Read and Comment):** Permission to `read`. /// - **"30" (Read and Write):** Permissions to `read` and `write`. /// - **"50" (Full Access):** Permissions to `read`, `write`, and `delete`. /// /// ## Matchers: /// - `m = r.sub == p.sub && p.obj == r.obj && g(p.act, r.act)` /// Evaluates whether the subject and object in the request match those in a policy and if the /// given role or access level authorizes the action. /// /// ## Examples: /// ### Policy 1 Evaluation (User Access with Role): /// ```text /// Request: api/workspace/123, uid=1, workspace_id=123, method=GET /// - `r = sub = 1, obj = 123, act = read` /// - `p = sub = 1, obj = 123, act = 1` (Policy in DB) /// Evaluation: /// - Subject Match: `r.sub == p.sub` /// - Object Match: `p.obj == r.obj` /// - Action Permission: `g(p.act, r.act) => g(1, read) => ["1", "read"]` /// Result: Allow /// ``` /// /// ### Policy 3 Evaluation (Group Access with Access Level): /// ```text /// Request: api/collab/123, uid=1, object_id=123, guid=g1, method=GET /// - `r = sub = g1, obj = 123, act = read` /// - `p = sub = g1, obj = 123, act = 50` (Policy in DB) /// Evaluation: /// - Subject Match: `r.sub == p.sub` /// - Object Match: `p.obj == r.obj` /// - Enforce by Access Level: `g(p.act, r.act) => g(50, read) => ["50", "read"]` /// Result: Allow /// ``` /// /// casbin model online writer: https://casbin.org/editor/ pub const MODEL_CONF: &str = r###" [request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [role_definition] g = _, _ # grouping rule [policy_effect] e = some(where (p.eft == allow)) [matchers] m = r.sub == p.sub && p.obj == r.obj && (g(p.act, r.act) || cmpRoleOrLevel(r.act, p.act)) "###; pub async fn casbin_model() -> Result { let model = casbin::DefaultModel::from_str(MODEL_CONF) .await .map_err(|e| AppError::Internal(anyhow!("Failed to create access control model: {}", e)))?; Ok(model) } /// Compares role or access level between a request and a policy. /// /// it is designed to compare roles or access levels specified in the request and policy. /// It supports two prefixes: "r:" for roles and "l:" for access levels. When the prefixes match, /// it compares the values to determine if the policy's role or level is greater than or equal to /// the request's role or level. /// /// # Arguments /// * `r_act` - The role or access level from the request, prefixed with "r:" for roles or "l:" for levels. /// * `p_act` - The role or access level from the policy, prefixed with "r:" for roles or "l:" for levels. /// pub fn cmp_role_or_level(r_act: ImmutableString, p_act: ImmutableString) -> Dynamic { trace!("cmp_role_or_level: r: {} p: {}", r_act, p_act); if r_act.starts_with("r:") && p_act.starts_with("r:") { let r = AFRole::from_enforce_act(r_act.as_str()); let p = AFRole::from_enforce_act(p_act.as_str()); return Dynamic::from_bool(p >= r); } if r_act.starts_with("l:") && p_act.starts_with("l:") { let r = AFAccessLevel::from_enforce_act(r_act.as_str()); let p = AFAccessLevel::from_enforce_act(p_act.as_str()); return Dynamic::from_bool(p >= r); } if r_act.starts_with("l:") && p_act.starts_with("r:") { let r = AFAccessLevel::from_enforce_act(r_act.as_str()); let role = AFRole::from_enforce_act(p_act.as_str()); let p = AFAccessLevel::from(&role); return Dynamic::from_bool(p >= r); } Dynamic::from_bool(false) } /// Represents the entity stored at the index of the access control policy. /// `subject_id, object_id, role/action` /// /// E.g. user1, collab::123, Owner /// pub const POLICY_FIELD_INDEX_SUBJECT: usize = 0; pub const POLICY_FIELD_INDEX_OBJECT: usize = 1; pub const POLICY_FIELD_INDEX_ACTION: usize = 2; /// Represents the entity stored at the index of the grouping. /// `role, action` /// /// E.g. Owner, Write #[allow(dead_code)] const GROUPING_FIELD_INDEX_ROLE: usize = 0; #[allow(dead_code)] const GROUPING_FIELD_INDEX_ACTION: usize = 1; pub(crate) async fn load_group_policies(enforcer: &mut CachedEnforcer) -> Result<(), AppError> { // Grouping definition of access level to action. let af_access_levels = [ AFAccessLevel::ReadOnly, AFAccessLevel::ReadAndComment, AFAccessLevel::ReadAndWrite, AFAccessLevel::FullAccess, ]; let mut grouping_policies = Vec::new(); for level in &af_access_levels { // All levels can read grouping_policies.push([level.to_enforce_act(), Action::Read.to_enforce_act()].to_vec()); if level.can_write() { grouping_policies.push([level.to_enforce_act(), Action::Write.to_enforce_act()].to_vec()); } if level.can_delete() { grouping_policies.push([level.to_enforce_act(), Action::Delete.to_enforce_act()].to_vec()); } } let af_roles = [AFRole::Owner, AFRole::Member, AFRole::Guest]; for role in &af_roles { match role { AFRole::Owner => { grouping_policies.push([role.to_enforce_act(), Action::Delete.to_enforce_act()].to_vec()); grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec()); grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec()); }, AFRole::Member => { grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec()); grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec()); }, AFRole::Guest => { grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec()); grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec()); }, } } enforcer .add_grouping_policies(grouping_policies) .await .map_err(|e| AppError::Internal(anyhow!("Failed to add grouping policies: {}", e)))?; Ok(()) } ================================================ FILE: libs/access-control/src/casbin/adapter.rs ================================================ use async_trait::async_trait; use crate::entity::ObjectType; use crate::metrics::AccessControlMetrics; use casbin::Adapter; use casbin::Filter; use casbin::Model; use casbin::Result; use database::pg_row::AFWorkspaceMemberPermRow; use database::workspace::select_workspace_member_perm_stream; use crate::act::Acts; use futures_util::stream::BoxStream; use sqlx::PgPool; use std::sync::Arc; use std::time::Instant; use tokio_stream::StreamExt; /// Implementation of [`casbin::Adapter`] for access control authorisation. /// Access control policies that are managed by workspace and collab CRUD. pub struct PgAdapter { pg_pool: PgPool, access_control_metrics: Arc, } impl PgAdapter { pub fn new(pg_pool: PgPool, access_control_metrics: Arc) -> Self { Self { pg_pool, access_control_metrics, } } } /// Loads workspace policies from a given stream of workspace member permissions. /// /// This function iterates over the stream of member permissions, constructing and accumulating /// policies for each member. A policy is represented as a vector of strings containing the user ID, /// object type (workspace), and action (derived from their role within the workspace). Additional /// policies are added for roles with implicit permissions (e.g., owners implicitly have member and /// guest permissions). /// /// # Arguments /// /// * `stream` - A stream of `sqlx::Result` representing the database /// query results for workspace member permissions. /// /// # Returns /// /// Returns a `Result>>`, which is a vector of policies. Each policy is itself a /// vector containing the user ID, policy object, and action as strings. In case of an error while /// processing the stream, returns the error encapsulated within `Result`. /// /// # Example Policy Vector /// /// For a workspace owner with user ID `1` and workspace ID `123`, the function generates policies /// such as: /// /// ```ignore /// [ /// ["1", "workspace:123", "owner"], /// ["1", "workspace:123", "member"], // Implicit permission for owner /// ["1", "workspace:123", "guest"], // Implicit permission for owner /// ] /// ``` /// /// # Note /// /// - The function handles additional policies for `Owner` and `Member` roles to include implicit /// permissions. For example, an `Owner` implicitly has `Member` and `Guest` permissions, and a /// `Member` implicitly has `Guest` permissions. /// - The policy object is derived from the `ObjectType::Workspace`, and actions are derived from /// member roles (`Owner`, `Member`, `Guest`) using the `to_action` method. pub async fn load_workspace_policies( mut stream: BoxStream<'_, sqlx::Result>, ) -> Result>> { let mut policies: Vec> = Vec::new(); while let Some(Ok(member_permission)) = stream.next().await { let uid = member_permission.uid; let workspace_id = member_permission.workspace_id.to_string(); let object_type = ObjectType::Workspace(workspace_id.to_string()); for act in member_permission.role.policy_acts() { let policy = vec![ uid.to_string(), object_type.policy_object(), act.to_string(), ]; policies.push(policy); } } Ok(policies) } #[async_trait] impl Adapter for PgAdapter { async fn load_policy(&mut self, model: &mut dyn Model) -> Result<()> { let start = Instant::now(); let workspace_member_perm_stream = select_workspace_member_perm_stream(&self.pg_pool); let workspace_policies = load_workspace_policies(workspace_member_perm_stream).await?; // Policy definition `p` of type `p`. See `model.conf` model.add_policies("p", "p", workspace_policies); self .access_control_metrics .record_load_all_policies_in_ms(start.elapsed().as_millis() as u64); Ok(()) } async fn load_filtered_policy<'a>(&mut self, m: &mut dyn Model, _f: Filter<'a>) -> Result<()> { // No support for filtered. self.load_policy(m).await } async fn save_policy(&mut self, _m: &mut dyn Model) -> Result<()> { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(()) } async fn clear_policy(&mut self) -> Result<()> { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(()) } fn is_filtered(&self) -> bool { // No support for filtered. false } async fn add_policy(&mut self, _sec: &str, _ptype: &str, _rule: Vec) -> Result { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(true) } async fn add_policies( &mut self, _sec: &str, _ptype: &str, _rules: Vec>, ) -> Result { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(true) } async fn remove_policy(&mut self, _sec: &str, _ptype: &str, _rule: Vec) -> Result { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(true) } async fn remove_policies( &mut self, _sec: &str, _ptype: &str, _rules: Vec>, ) -> Result { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(true) } async fn remove_filtered_policy( &mut self, _sec: &str, _ptype: &str, _field_index: usize, _field_values: Vec, ) -> Result { // unimplemented!() // // Adapter is used only for loading policies from database // since policies are managed by workspace and collab CRUD. Ok(true) } } ================================================ FILE: libs/access-control/src/casbin/collab.rs ================================================ use crate::{ act::Action, collab::{CollabAccessControl, RealtimeAccessControl}, entity::ObjectType, }; use app_error::AppError; use async_trait::async_trait; use database_entity::dto::AFAccessLevel; use tracing::instrument; use uuid::Uuid; use super::access::AccessControl; #[derive(Clone)] pub struct CollabAccessControlImpl { access_control: AccessControl, } impl CollabAccessControlImpl { pub fn new(access_control: AccessControl) -> Self { Self { access_control } } } #[async_trait] impl CollabAccessControl for CollabAccessControlImpl { async fn enforce_action( &self, workspace_id: &Uuid, uid: &i64, _oid: &Uuid, action: Action, ) -> Result<(), AppError> { // TODO: allow non workspace member to read a collab. // Anyone who can write to a workspace, can also delete a collab. let workspace_action = match action { Action::Read => Action::Read, Action::Write => Action::Write, Action::Delete => Action::Write, }; let result = self .access_control .enforce_immediately( uid, ObjectType::Workspace(workspace_id.to_string()), workspace_action, ) .await; match result { Ok(true) => Ok(()), Ok(false) => Err(AppError::NotEnoughPermissions), Err(e) => Err(e), } } async fn enforce_access_level( &self, workspace_id: &Uuid, uid: &i64, _oid: &Uuid, access_level: AFAccessLevel, ) -> Result<(), AppError> { // TODO: allow non workspace member to read a collab. // Anyone who can write to a workspace, also have full access to a collab. let workspace_action = match access_level { AFAccessLevel::ReadOnly => Action::Read, AFAccessLevel::ReadAndComment => Action::Read, AFAccessLevel::ReadAndWrite => Action::Write, AFAccessLevel::FullAccess => Action::Write, }; let result = self .access_control .enforce_immediately( uid, ObjectType::Workspace(workspace_id.to_string()), workspace_action, ) .await; match result { Ok(true) => Ok(()), Ok(false) => Err(AppError::NotEnoughPermissions), Err(e) => Err(e), } } #[instrument(level = "info", skip_all)] async fn update_access_level_policy( &self, _uid: &i64, _oid: &Uuid, _level: AFAccessLevel, ) -> Result<(), AppError> { // TODO: allow non workspace member to read a collab. Ok(()) } #[instrument(level = "info", skip_all)] async fn remove_access_level(&self, _uid: &i64, _oid: &Uuid) -> Result<(), AppError> { // TODO: allow non workspace member to read a collab. Ok(()) } } #[derive(Clone)] pub struct RealtimeCollabAccessControlImpl { access_control: AccessControl, } impl RealtimeCollabAccessControlImpl { pub fn new(access_control: AccessControl) -> Self { Self { access_control } } async fn can_perform_action( &self, workspace_id: &Uuid, uid: &i64, _oid: &Uuid, required_action: Action, ) -> Result { // TODO: allow non workspace member to read a collab. // Anyone who can write to a workspace, can also delete a collab. let workspace_action = match required_action { Action::Read => Action::Read, Action::Write => Action::Write, Action::Delete => Action::Write, }; self .access_control .enforce_immediately( uid, ObjectType::Workspace(workspace_id.to_string()), workspace_action, ) .await } } #[async_trait] impl RealtimeAccessControl for RealtimeCollabAccessControlImpl { async fn can_write_collab( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, ) -> Result { self .can_perform_action(workspace_id, uid, oid, Action::Write) .await } async fn can_read_collab( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, ) -> Result { self .can_perform_action(workspace_id, uid, oid, Action::Read) .await } } #[cfg(test)] mod tests { use database_entity::dto::AFRole; use uuid::Uuid; use crate::casbin::util::tests::test_enforcer_v2; use crate::{ act::Action, casbin::access::AccessControl, collab::CollabAccessControl, entity::{ObjectType, SubjectType}, }; #[tokio::test] pub async fn test_collab_access_control() { let enforcer = test_enforcer_v2().await; let uid = 1; let workspace_id = Uuid::new_v4(); let oid = Uuid::new_v4(); enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Member, ) .await .unwrap(); let access_control = AccessControl::with_enforcer(enforcer); let collab_access_control = super::CollabAccessControlImpl::new(access_control); for action in [Action::Read, Action::Write, Action::Delete] { collab_access_control .enforce_action(&workspace_id, &uid, &oid, action.clone()) .await .unwrap_or_else(|_| panic!("Failed to enforce action: {:?}", action)); } } } ================================================ FILE: libs/access-control/src/casbin/enforcer.rs ================================================ use super::access::load_group_policies; use crate::act::Acts; use crate::casbin::util::policies_for_subject_with_given_object; use crate::entity::{ObjectType, SubjectType}; use crate::metrics::MetricsCalState; use crate::request::PolicyRequest; use anyhow::anyhow; use app_error::AppError; use casbin::{CachedEnforcer, CoreApi, MgmtApi}; use rand::Rng; use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tokio::time::sleep; use tracing::{event, instrument, trace, warn}; /// Configuration for retry logic with exponential backoff #[derive(Clone, Debug)] pub(crate) struct RetryConfig { /// Base delay for exponential backoff (before jitter) pub base_delay: Duration, /// Maximum delay between retries (cap for exponential backoff) pub max_delay: Duration, /// Maximum number of retry attempts pub max_retries: usize, /// Total timeout for all retry attempts pub timeout: Duration, /// Initial random delay range to prevent immediate thundering herd pub initial_jitter_max: Duration, } impl Default for RetryConfig { fn default() -> Self { Self { base_delay: Duration::from_millis(100), max_delay: Duration::from_millis(1000), max_retries: 50, timeout: Duration::from_secs(5), initial_jitter_max: Duration::from_millis(50), } } } #[cfg(test)] pub struct AFEnforcer { enforcer: RwLock, pub(crate) metrics_state: MetricsCalState, } #[cfg(test)] impl AFEnforcer { pub async fn new(mut enforcer: CachedEnforcer) -> Result { load_group_policies(&mut enforcer).await?; Ok(Self { enforcer: RwLock::new(enforcer), metrics_state: MetricsCalState::new(), }) } /// Retry acquiring a write lock with default configuration async fn retry_write( &self, ) -> Result, AppError> { self.retry_write_with_config(RetryConfig::default()).await } /// Calculate next delay using decorrelated jitter strategy /// Decorrelated jitter: delay = random(base_delay, last_delay * 3) pub(crate) fn calculate_next_delay(last_delay: &mut Duration, config: &RetryConfig) -> Duration { let mut rng = rand::thread_rng(); let min_delay = config.base_delay.as_millis() as u64; let max_delay = std::cmp::min( config.max_delay.as_millis() as u64, last_delay.saturating_mul(3).as_millis() as u64, ); let jitter_ms = rng.gen_range(min_delay..=max_delay.max(min_delay)); let new_delay = Duration::from_millis(jitter_ms); *last_delay = new_delay; new_delay } /// Generate initial random delay to prevent immediate thundering herd pub(crate) fn generate_initial_delay(max_delay: Duration) -> Duration { if max_delay == Duration::ZERO { return Duration::ZERO; } let mut rng = rand::thread_rng(); let delay_ms = rng.gen_range(0..=max_delay.as_millis().max(1) as u64); Duration::from_millis(delay_ms) } /// Retry acquiring a write lock with improved concurrent handling /// Uses advanced jitter strategies to prevent thundering herd #[instrument(level = "debug", skip_all)] pub(crate) async fn retry_write_with_config( &self, config: RetryConfig, ) -> Result, AppError> { let start_time = Instant::now(); // Add initial random delay to prevent immediate thundering herd if config.initial_jitter_max > Duration::ZERO { let initial_delay = Self::generate_initial_delay(config.initial_jitter_max); sleep(initial_delay).await; } let mut last_delay = config.base_delay; let mut attempt = 0; loop { // Primary constraint: Check timeout first let elapsed = start_time.elapsed(); if elapsed >= config.timeout { warn!( "Timeout while acquiring write lock after {} attempts in {:?}", attempt, elapsed ); return Err(AppError::RetryLater(anyhow!( "Timeout while acquiring write lock after {} attempts in {:?}", attempt, elapsed ))); } match self.enforcer.try_write() { Ok(guard) => { if attempt > 0 { trace!( "Successfully acquired write lock after {} attempts in {:?}", attempt + 1, elapsed ); } return Ok(guard); }, Err(_) => { attempt += 1; // Calculate next delay let delay = Self::calculate_next_delay(&mut last_delay, &config); // Check if delay would exceed timeout (primary constraint) if start_time.elapsed() + delay >= config.timeout { warn!( "Next retry delay ({:?}) would exceed timeout, stopping after {} attempts in {:?}", delay, attempt, start_time.elapsed() ); return Err(AppError::RetryLater(anyhow!( "Would exceed timeout with next retry delay after {} attempts in {:?}", attempt, start_time.elapsed() ))); } // Secondary constraint: Safety limit to prevent infinite retries (only if we have a bug) if attempt >= config.max_retries { warn!( "🚨 Reached maximum retry safety limit ({}) - this should rarely happen! Elapsed: {:?}", config.max_retries, start_time.elapsed() ); return Err(AppError::RetryLater(anyhow!( "Reached maximum retry safety limit ({}) after {:?}", config.max_retries, start_time.elapsed() ))); } trace!( "Failed to acquire write lock on attempt {}, retrying after {:?} (elapsed: {:?})", attempt, delay, start_time.elapsed() ); sleep(delay).await; }, } } } /// Update policy for a user. /// If the policy is already exist, then it will return Ok(false). /// /// [`ObjectType::Workspace`] has to be paired with [`ActionType::Role`], /// [`ObjectType::Collab`] has to be paired with [`ActionType::Level`], #[instrument(level = "debug", skip_all, err)] pub async fn update_policy( &self, sub: SubjectType, obj: ObjectType, act: T, ) -> Result<(), AppError> where T: Acts, { let policies = act .policy_acts() .into_iter() .map(|act| vec![sub.policy_subject(), obj.policy_object(), act]) .collect::>>(); trace!("[access control]: add policy:{:?}", policies); // DEADLOCK PREVENTION: // We use retry_write() instead of self.enforcer.write().await to prevent deadlocks. // // Problem with write().await: // 1. write().await can block indefinitely waiting for the lock // 2. If the lock is held while calling .await on add_policies(), the task yields to the runtime // 3. Other tasks on the same thread may then try to acquire the same write lock // 4. If casbin internally uses synchronous locks that depend on this operation completing, // we get a circular dependency: Task A holds async lock → waits for sync lock → // Task B holds sync lock → waits for async lock → DEADLOCK let mut enforcer = self.retry_write().await?; enforcer .add_policies(policies) .await .map_err(|e| AppError::Internal(anyhow!("fail to add policy: {e:?}")))?; Ok(()) } /// Returns policies that match the filter. #[allow(dead_code)] pub async fn remove_policy( &self, sub: SubjectType, object_type: ObjectType, ) -> Result<(), AppError> { let policies_for_user_on_object = { let enforcer = self.enforcer.read().await; policies_for_subject_with_given_object(sub.clone(), object_type.clone(), &enforcer).await }; event!( tracing::Level::INFO, "[access control]: remove policy:subject={}, object={}, policies={:?}", sub.policy_subject(), object_type.policy_object(), policies_for_user_on_object ); // DEADLOCK PREVENTION: // We use retry_write() instead of self.enforcer.write().await to prevent deadlocks. // // Problem with write().await: // 1. write().await can block indefinitely waiting for the lock // 2. If the lock is held while calling .await on add_policies(), the task yields to the runtime // 3. Other tasks on the same thread may then try to acquire the same write lock // 4. If casbin internally uses synchronous locks that depend on this operation completing, // we get a circular dependency: Task A holds async lock → waits for sync lock → // Task B holds sync lock → waits for async lock → DEADLOCK let mut enforcer = self.retry_write().await?; enforcer .remove_policies(policies_for_user_on_object) .await .map_err(|e| AppError::Internal(anyhow!("error enforce: {e:?}")))?; Ok(()) } /// ## Parameters: /// - `uid`: The user ID of the user attempting the action. /// - `obj`: The type of object being accessed, encapsulated within an `ObjectType`. /// - `act`: The action being attempted, encapsulated within an `ActionVariant`. /// /// ## Returns: /// - `Ok(true)`: If the user is authorized to perform the action based on any of the evaluated policies. /// - `Ok(false)`: If none of the policies authorize the user to perform the action. /// - `Err(AppError)`: If an error occurs during policy enforcement. /// #[instrument(level = "debug", skip_all)] pub async fn enforce_policy( &self, uid: &i64, obj: ObjectType, act: T, ) -> Result where T: Acts, { self .metrics_state .total_read_enforce_result .fetch_add(1, Ordering::Relaxed); let policy_request = PolicyRequest::new(*uid, obj, act); let policy = policy_request.to_policy(); let result = self .enforcer .read() .await .enforce(policy) .map_err(|e| AppError::Internal(anyhow!("enforce: {e:?}")))?; Ok(result) } } #[cfg(test)] pub(crate) mod tests { use crate::{ act::Action, casbin::access::{casbin_model, cmp_role_or_level}, entity::{ObjectType, SubjectType}, }; use casbin::{function_map::OperatorFunction, prelude::*}; use database_entity::dto::{AFAccessLevel, AFRole}; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Barrier; use super::{AFEnforcer, RetryConfig}; pub async fn test_enforcer() -> AFEnforcer { let model = casbin_model().await.unwrap(); let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default()) .await .unwrap(); enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); AFEnforcer::new(enforcer).await.unwrap() } #[tokio::test] async fn test_retry_config_defaults() { let config = RetryConfig::default(); assert_eq!(config.base_delay, Duration::from_millis(100)); assert_eq!(config.max_delay, Duration::from_millis(1000)); assert_eq!(config.max_retries, 50); assert_eq!(config.timeout, Duration::from_secs(5)); assert_eq!(config.initial_jitter_max, Duration::from_millis(50)); } #[tokio::test] async fn test_decorrelated_jitter_delay_calculation() { let config = RetryConfig::default(); let mut last_delay = config.base_delay; // Test multiple delay calculations to ensure they're within expected bounds for _ in 0..10 { let delay = AFEnforcer::calculate_next_delay(&mut last_delay, &config); // Delay should be between base_delay and max_delay assert!(delay >= config.base_delay); assert!(delay <= config.max_delay); // Delay should be at least base_delay assert!(delay.as_millis() >= config.base_delay.as_millis()); } } #[tokio::test] async fn test_decorrelated_jitter_progression() { let config = RetryConfig { base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(500), max_retries: 5, timeout: Duration::from_secs(10), initial_jitter_max: Duration::ZERO, // Disable initial jitter for predictable testing }; let mut last_delay = config.base_delay; let mut delays = Vec::new(); // Generate a sequence of delays for _ in 0..5 { let delay = AFEnforcer::calculate_next_delay(&mut last_delay, &config); delays.push(delay); } // Verify delays are within bounds and show variation for delay in &delays { assert!(delay >= &config.base_delay); assert!(delay <= &config.max_delay); } // Verify we have some variation (not all delays are the same) let all_same = delays.windows(2).all(|w| w[0] == w[1]); assert!(!all_same, "Delays should show variation due to jitter"); } #[tokio::test] async fn test_initial_jitter_generation() { let max_delay = Duration::from_millis(100); // Generate multiple initial delays to test variation let mut delays = Vec::new(); for _ in 0..10 { let delay = AFEnforcer::generate_initial_delay(max_delay); delays.push(delay); assert!(delay <= max_delay); } // Test zero max delay let zero_delay = AFEnforcer::generate_initial_delay(Duration::ZERO); assert_eq!(zero_delay, Duration::ZERO); // Verify we have some variation let all_same = delays.windows(2).all(|w| w[0] == w[1]); assert!(!all_same, "Initial delays should show variation"); } #[tokio::test] async fn test_retry_write_success_on_first_attempt() { let enforcer = test_enforcer().await; let start = Instant::now(); // Should succeed immediately since no contention let _guard = enforcer.retry_write().await.unwrap(); let elapsed = start.elapsed(); // Should complete quickly (within 100ms) assert!(elapsed < Duration::from_millis(100)); } #[tokio::test] async fn test_retry_write_with_custom_config() { let enforcer = test_enforcer().await; let config = RetryConfig { base_delay: Duration::from_millis(5), max_delay: Duration::from_millis(50), max_retries: 3, timeout: Duration::from_millis(200), initial_jitter_max: Duration::from_millis(10), }; let start = Instant::now(); let _guard = enforcer.retry_write_with_config(config).await.unwrap(); let elapsed = start.elapsed(); // Should complete quickly since no contention, but may have initial jitter assert!(elapsed < Duration::from_millis(100)); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_retry_behavior() { let enforcer = Arc::new(test_enforcer().await); let barrier = Arc::new(Barrier::new(5)); let mut handles = Vec::new(); // Spawn 5 concurrent tasks that will try to get write locks for i in 0..5 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { // Wait for all tasks to be ready barrier_clone.wait().await; let start = Instant::now(); let result = enforcer_clone.retry_write().await; let elapsed = start.elapsed(); (i, result.is_ok(), elapsed) }); handles.push(handle); } // Wait for all tasks to complete let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // All should succeed eventually let successful_count = results.iter().filter(|(_, success, _)| *success).count(); assert_eq!( successful_count, 5, "All concurrent requests should eventually succeed" ); // Some should take longer than others due to retries and jitter let times: Vec = results.iter().map(|(_, _, elapsed)| *elapsed).collect(); let min_time = times.iter().min().unwrap(); let max_time = times.iter().max().unwrap(); // There should be some spread in completion times due to jitter println!( "Concurrent retry times: min={:?}, max={:?}", min_time, max_time ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_retry_timeout() { let enforcer = Arc::new(test_enforcer().await); // Hold a write lock to force retries let _blocking_guard = enforcer.retry_write().await.unwrap(); let config = RetryConfig { base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(50), max_retries: 10, timeout: Duration::from_millis(100), // Short timeout initial_jitter_max: Duration::ZERO, }; let start = Instant::now(); let result = enforcer.retry_write_with_config(config).await; let elapsed = start.elapsed(); // Should timeout and return error assert!(result.is_err()); // The retry logic may exit early when it determines the next delay would exceed timeout // This is good optimization behavior, so we don't enforce a minimum time // Just verify it doesn't take unreasonably long assert!( elapsed < Duration::from_millis(200), "Should not take too long even when timing out: {:?}", elapsed ); // Verify the error message indicates a timeout/retry issue if let Err(app_error) = result { let error_msg = format!("{}", app_error); assert!( error_msg.contains("timeout") || error_msg.contains("retry") || error_msg.contains("Timeout") || error_msg.contains("RetryLater"), "Error should indicate timeout or retry issue: {}", error_msg ); } } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_high_concurrency_jitter_effectiveness() { let enforcer = Arc::new(test_enforcer().await); let barrier = Arc::new(Barrier::new(10)); let mut handles = Vec::new(); // Spawn 10 concurrent tasks to test jitter effectiveness for i in 0..10 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let config = RetryConfig { base_delay: Duration::from_millis(5), max_delay: Duration::from_millis(100), max_retries: 5, timeout: Duration::from_secs(2), initial_jitter_max: Duration::from_millis(20), }; let start = Instant::now(); let result = enforcer_clone.retry_write_with_config(config).await; let elapsed = start.elapsed(); (i, result.is_ok(), elapsed) }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // All should succeed let successful_count = results.iter().filter(|(_, success, _)| *success).count(); assert_eq!( successful_count, 10, "All high-concurrency requests should succeed" ); // Completion times should be well distributed due to jitter let times: Vec = results.iter().map(|(_, _, elapsed)| *elapsed).collect(); let mut times_ms: Vec = times.iter().map(|d| d.as_millis()).collect(); times_ms.sort(); println!("High concurrency completion times (ms): {:?}", times_ms); // Check that times are reasonably spread out (not all clustered) let first_quartile = times_ms[2]; // 3rd fastest let third_quartile = times_ms[7]; // 8th fastest let spread = third_quartile.saturating_sub(first_quartile); // There should be meaningful spread between completion times // Lower threshold since jitter is working so well that contention is minimal assert!( spread > 2, "Completion times should show good spread due to jitter, got spread: {}ms", spread ); } #[tokio::test] async fn policy_comparison_test() { let enforcer = test_enforcer().await; let uid = 1; let workspace_id = "w1"; // add user as a member of the workspace enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Member, ) .await .expect("update policy failed"); // test if enforce can compare requested action and the role policy for action in [Action::Write, Action::Read] { let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), action.clone(), ) .await .unwrap_or_else(|_| panic!("enforcing action={:?} failed", action)); assert!(result, "action={:?} should be allowed", action); } let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), Action::Delete, ) .await .expect("enforcing action=Delete failed"); assert!(!result, "action=Delete should not be allowed"); let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Member, ) .await .expect("enforcing role=Member failed"); assert!(result, "role=Member should be allowed"); let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .expect("enforcing role=Owner failed"); assert!(!result, "role=Owner should not be allowed"); for access_level in [ AFAccessLevel::ReadOnly, AFAccessLevel::ReadAndComment, AFAccessLevel::ReadAndWrite, ] { let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), access_level, ) .await .unwrap_or_else(|_| panic!("enforcing access_level={:?} failed", access_level)); assert!(result, "access_level={:?} should be allowed", access_level); } let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFAccessLevel::FullAccess, ) .await .expect("enforcing access_level=FullAccess failed"); assert!(!result, "access_level=FullAccess should not be allowed") } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_update_policy_operations() { // Test with 100 concurrent operations to really stress the retry logic test_concurrent_update_policy_operations_with_count(100).await; } // Helper function to test concurrent update operations with configurable count async fn test_concurrent_update_policy_operations_with_count(concurrent_count: usize) { let enforcer = Arc::new(test_enforcer().await); let barrier = Arc::new(Barrier::new(concurrent_count)); let mut handles = Vec::new(); println!( "Testing {} concurrent update_policy operations", concurrent_count ); // Spawn concurrent tasks for update_policy operations for i in 0..concurrent_count { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let user_id = 1000 + i as i64; // Different users let workspace_id = format!("workspace_{}", i % 5); // 5 different workspaces for more contention let role = if i % 3 == 0 { AFRole::Owner } else if i % 3 == 1 { AFRole::Member } else { AFRole::Guest }; let start = Instant::now(); let result = enforcer_clone .update_policy( SubjectType::User(user_id), ObjectType::Workspace(workspace_id), role, ) .await; let elapsed = start.elapsed(); (i, result.is_ok(), elapsed) }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // All policy updates should succeed let successful_count = results.iter().filter(|(_, success, _)| *success).count(); assert_eq!( successful_count, concurrent_count, "All {} concurrent update_policy operations should succeed", concurrent_count ); // Analyze timing distribution to show jitter effectiveness let times: Vec = results.iter().map(|(_, _, elapsed)| *elapsed).collect(); let mut times_ms: Vec = times.iter().map(|d| d.as_millis()).collect(); times_ms.sort(); println!( "Concurrent update_policy completion times (first 10): {:?}", ×_ms[..10.min(times_ms.len())] ); if times_ms.len() > 10 { println!( "Concurrent update_policy completion times (last 10): {:?}", ×_ms[times_ms.len() - 10..] ); } // Verify reasonable spread in completion times due to jitter let min_time = times_ms[0]; let max_time = times_ms[concurrent_count - 1]; let spread = max_time.saturating_sub(min_time); let median_time = times_ms[concurrent_count / 2]; let percentile_95 = times_ms[(concurrent_count * 95) / 100]; println!( "Update policy stats: min={}ms, median={}ms, 95th={}ms, max={}ms, spread={}ms", min_time, median_time, percentile_95, max_time, spread ); // With higher concurrency, we expect significant timing spread due to jitter let expected_min_spread = if concurrent_count >= 50 { 20 } else { 5 }; assert!( spread > expected_min_spread, "Should show timing spread due to concurrent write lock contention: {}ms (expected > {}ms)", spread, expected_min_spread ); // Verify that operations complete in reasonable time even under high load assert!( max_time < 5000, // 5 seconds max "Even under high concurrency, operations should complete within 5 seconds: {}ms", max_time ); // Check distribution - no more than 20% should complete at the same time let timing_distribution = times_ms.iter().fold(HashMap::new(), |mut acc, &time| { *acc.entry(time).or_insert(0) += 1; acc }); let max_same_timing = timing_distribution.values().max().unwrap_or(&0); let max_allowed_clustering = (times_ms.len() * 20) / 100; // 20% threshold assert!( *max_same_timing <= max_allowed_clustering, "Too many operations completed at the same time: {} out of {} (jitter should distribute better)", max_same_timing, times_ms.len() ); println!( "Successfully completed {} concurrent update operations with excellent distribution!", concurrent_count ); } // Additional test with different concurrency levels #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_scalable_concurrent_update_policy_operations() { // Test different scales to verify jitter effectiveness across various loads for &count in &[10, 25, 50] { println!("\n=== Testing {} concurrent operations ===", count); test_concurrent_update_policy_operations_with_count(count).await; } } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_enforce_policy_operations() { let enforcer = Arc::new(test_enforcer().await); // First, set up some policies for testing for i in 0..3 { let user_id = 2000 + i; let workspace_id = format!("test_workspace_{}", i); enforcer .update_policy( SubjectType::User(user_id), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await .expect("Failed to set up test policy"); } let barrier = Arc::new(Barrier::new(15)); let mut handles = Vec::new(); // Simulate 15 concurrent enforce_policy operations for i in 0..15 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let user_id = 2000 + (i % 3); // Use the users we set up let workspace_id = format!("test_workspace_{}", i % 3); let action = if i % 3 == 0 { Action::Read } else if i % 3 == 1 { Action::Write } else { Action::Delete }; let start = Instant::now(); let result = enforcer_clone .enforce_policy( &user_id, ObjectType::Workspace(workspace_id), action.clone(), ) .await; let elapsed = start.elapsed(); (i, result.is_ok(), elapsed, result.unwrap_or(false)) }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // All enforce operations should succeed (though some may be denied by policy) let successful_count = results.iter().filter(|(_, success, _, _)| *success).count(); assert_eq!( successful_count, 15, "All concurrent enforce_policy operations should succeed" ); // Count how many were actually allowed by policy let allowed_count = results.iter().filter(|(_, _, _, allowed)| *allowed).count(); println!( "Policy enforcement results: {} out of {} actions were allowed", allowed_count, results.len() ); // Analyze timing distribution let times: Vec = results.iter().map(|(_, _, elapsed, _)| *elapsed).collect(); let mut times_ms: Vec = times.iter().map(|d| d.as_millis()).collect(); times_ms.sort(); println!( "Concurrent enforce_policy completion times (ms): {:?}", times_ms ); // Even read operations can benefit from jitter if there's write contention let max_time = times_ms[14]; println!("Max enforce_policy time: {}ms", max_time); // All should complete reasonably quickly since these are mostly read operations assert!( max_time < 100, "Enforce operations should complete quickly: {}ms", max_time ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_mixed_concurrent_operations_realistic_scenario() { let enforcer = Arc::new(test_enforcer().await); let barrier = Arc::new(Barrier::new(20)); let mut handles = Vec::new(); // Simulate realistic mixed workload: 20 operations total // 30% update_policy (write operations) // 70% enforce_policy (read operations) for i in 0..20 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); // 30% are update operations, 70% are enforce operations let operation_result = if i < 6 { // Update policy operations (write-heavy) let user_id = 3000 + i; let workspace_id = format!("mixed_workspace_{}", i % 2); let role = AFRole::Member; let result = enforcer_clone .update_policy( SubjectType::User(user_id), ObjectType::Workspace(workspace_id), role, ) .await; ("update", result.is_ok(), result.is_ok()) } else { // Enforce policy operations (read-heavy) let user_id = 3000 + (i % 6); // Refer to users created by update operations let workspace_id = format!("mixed_workspace_{}", i % 2); let action = Action::Read; let result = enforcer_clone .enforce_policy(&user_id, ObjectType::Workspace(workspace_id), action) .await; match result { Ok(allowed) => ("enforce", true, allowed), Err(_) => ("enforce", false, false), } }; let elapsed = start.elapsed(); ( i, operation_result.0, operation_result.1, operation_result.2, elapsed, ) }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // Analyze results by operation type let update_results: Vec<_> = results .iter() .filter(|(_, op_type, _, _, _)| *op_type == "update") .collect(); let enforce_results: Vec<_> = results .iter() .filter(|(_, op_type, _, _, _)| *op_type == "enforce") .collect(); println!("Mixed workload results:"); println!("- Update operations: {} total", update_results.len()); println!("- Enforce operations: {} total", enforce_results.len()); // All operations should succeed let all_successful = results.iter().all(|(_, _, success, _, _)| *success); assert!( all_successful, "All mixed concurrent operations should succeed" ); // Analyze timing patterns let update_times: Vec = update_results .iter() .map(|(_, _, _, _, elapsed)| elapsed.as_millis()) .collect(); let enforce_times: Vec = enforce_results .iter() .map(|(_, _, _, _, elapsed)| elapsed.as_millis()) .collect(); if !update_times.is_empty() { let avg_update_time = update_times.iter().sum::() / update_times.len() as u128; println!("- Average update_policy time: {}ms", avg_update_time); } if !enforce_times.is_empty() { let avg_enforce_time = enforce_times.iter().sum::() / enforce_times.len() as u128; println!("- Average enforce_policy time: {}ms", avg_enforce_time); } // All times collected for overall analysis let all_times: Vec = results .iter() .map(|(_, _, _, _, elapsed)| elapsed.as_millis()) .collect(); let mut sorted_times = all_times.clone(); sorted_times.sort(); println!( "- Mixed operation completion times (ms): {:?}", sorted_times ); let min_time = sorted_times[0]; let max_time = sorted_times[19]; let spread = max_time.saturating_sub(min_time); println!( "- Timing spread: {}ms (min: {}ms, max: {}ms)", spread, min_time, max_time ); // The jitter should help distribute the load even in mixed scenarios assert!( spread > 3, "Mixed workload should show timing distribution due to jitter: {}ms", spread ); // For mixed workload, fast read operations (0ms) are expected and excellent // Only check write operation distribution to avoid penalizing good read performance let write_times: Vec = update_results .iter() .map(|(_, _, _, _, elapsed)| elapsed.as_millis()) .collect(); if write_times.len() > 1 { let write_distribution = write_times.iter().fold(HashMap::new(), |mut acc, &time| { *acc.entry(time).or_insert(0) += 1; acc }); let max_same_write_timing = write_distribution.values().max().unwrap_or(&0); let max_allowed_write_clustering = (write_times.len() * 60) / 100; // 60% threshold for write operations assert!(*max_same_write_timing <= max_allowed_write_clustering, "Too many write operations completed at the same time: {} out of {} (jitter should distribute writes better)", max_same_write_timing, write_times.len()); } println!( "Mixed workload distribution is excellent - fast reads (0ms) and well-distributed writes" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_high_contention_write_operations() { let enforcer = Arc::new(test_enforcer().await); let barrier = Arc::new(Barrier::new(12)); let mut handles = Vec::new(); // Simulate high write contention: multiple updates to the same workspace let shared_workspace = "high_contention_workspace".to_string(); for i in 0..12 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let workspace_id = shared_workspace.clone(); let handle = tokio::spawn(async move { barrier_clone.wait().await; let user_id = 4000 + i; let role = if i % 3 == 0 { AFRole::Owner } else if i % 3 == 1 { AFRole::Member } else { AFRole::Guest }; let start = Instant::now(); let result = enforcer_clone .update_policy( SubjectType::User(user_id), ObjectType::Workspace(workspace_id), role, ) .await; let elapsed = start.elapsed(); (i, result.is_ok(), elapsed) }); handles.push(handle); } let mut results = Vec::new(); for handle in handles { results.push(handle.await.unwrap()); } // All high-contention writes should succeed let successful_count = results.iter().filter(|(_, success, _)| *success).count(); assert_eq!( successful_count, 12, "All high-contention write operations should succeed" ); // Analyze the retry effectiveness under high write contention let times: Vec = results.iter().map(|(_, _, elapsed)| *elapsed).collect(); let mut times_ms: Vec = times.iter().map(|d| d.as_millis()).collect(); times_ms.sort(); println!( "High contention write operations completion times (ms): {:?}", times_ms ); let min_time = times_ms[0]; let max_time = times_ms[11]; let spread = max_time.saturating_sub(min_time); let median_time = times_ms[6]; println!( "High contention stats: min={}ms, median={}ms, max={}ms, spread={}ms", min_time, median_time, max_time, spread ); // Under high contention, we expect significant timing spread due to retries and jitter assert!( spread > 10, "High write contention should show significant timing spread: {}ms", spread ); // Some operations should take longer due to retries assert!( max_time > min_time * 2, "Some operations should take significantly longer due to retries" ); // But all should still complete in reasonable time assert!( max_time < 1000, "Even under high contention, operations should complete within 1 second: {}ms", max_time ); } } ================================================ FILE: libs/access-control/src/casbin/enforcer_v2.rs ================================================ use super::access::load_group_policies; use crate::act::Acts; use crate::casbin::util::policies_for_subject_with_given_object; use crate::entity::{ObjectType, SubjectType}; use crate::metrics::MetricsCalState; use crate::request::PolicyRequest; use anyhow::anyhow; use app_error::AppError; use casbin::{CachedApi, CachedEnforcer, CoreApi, MgmtApi}; use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, Notify, RwLock}; use tokio::time::timeout; use tracing::{error, event, info, instrument, trace, warn}; /// Consistency mode for policy enforcement #[derive(Debug, Clone, Default, Copy)] pub enum ConsistencyMode { /// Default mode - returns immediately with potentially stale data #[default] Eventual, /// Waits for all pending updates to complete before enforcing Strong, /// Waits for specific pending updates affecting the requested subject/object BoundedStrong { timeout_ms: u64 }, } /// Commands for policy updates #[derive(Debug)] enum PolicyCommand { AddPolicies { policies: Vec>, generation: u64, subject_object_keys: Vec<(String, String)>, // (subject, object) pairs response: tokio::sync::oneshot::Sender>, }, RemovePolicies { policies: Vec>, generation: u64, subject_object_keys: Vec<(String, String)>, // (subject, object) pairs response: tokio::sync::oneshot::Sender>, }, Shutdown, } pub struct AFEnforcerV2 { enforcer: Arc>, pub(crate) metrics_state: MetricsCalState, policy_cmd_tx: mpsc::Sender, /// Tracks the current generation of policy updates generation: Arc, /// Tracks the last processed generation processed_generation: Arc, /// Tracks pending operations by subject-object key pending_operations: Arc>>, /// Notifies when a generation has been processed generation_notify: Arc, } impl AFEnforcerV2 { pub async fn new(enforcer: CachedEnforcer) -> Result { Self::new_internal(enforcer).await } pub async fn new_with_redis( mut enforcer: CachedEnforcer, redis_uri: &str, ) -> Result { use super::redis_cache::RedisCache; match RedisCache::new(redis_uri) { Ok(redis_cache) => { info!("[access control v2]: Using Redis cache at {}", redis_uri); enforcer.set_cache(Box::new(redis_cache)); Self::new_internal(enforcer).await }, Err(e) => { warn!( "[access control v2]: Failed to connect to Redis cache: {}. Using in-memory cache.", e ); Self::new_internal(enforcer).await }, } } async fn new_internal(mut enforcer: CachedEnforcer) -> Result { load_group_policies(&mut enforcer).await?; // Create command channel with bounded capacity // Read capacity from environment variable, defaulting to 2000 if not set or invalid let channel_capacity = std::env::var("ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(2000); trace!( "[access control v2]: Policy channel capacity set to {}", channel_capacity ); let (tx, rx) = mpsc::channel::(channel_capacity); let enforcer = Arc::new(RwLock::new(enforcer)); // Create consistency tracking let generation = Arc::new(AtomicU64::new(0)); let processed_generation = Arc::new(AtomicU64::new(0)); let pending_operations = Arc::new(RwLock::new(HashSet::new())); let generation_notify = Arc::new(Notify::new()); // Spawn processor with consistency tracking tokio::spawn(Self::policy_update_processor( rx, enforcer.clone(), processed_generation.clone(), pending_operations.clone(), generation_notify.clone(), )); Ok(Self { enforcer, metrics_state: MetricsCalState::new(), policy_cmd_tx: tx, generation, processed_generation, pending_operations, generation_notify, }) } /// Background task that processes policy updates sequentially async fn policy_update_processor( mut rx: mpsc::Receiver, enforcer: Arc>, processed_generation: Arc, pending_operations: Arc>>, generation_notify: Arc, ) { info!("[access control v2]: Policy update processor started"); let buffer_size = 2; let mut buf = Vec::with_capacity(buffer_size); loop { trace!("[access control v2]: Waiting for policy commands..."); let n = rx.recv_many(&mut buf, buffer_size).await; if n == 0 { info!("[access control v2]: Channel closed, exiting processor"); break; } info!("[access control v2]: Received {} policy commands", n); let mut enforcer = enforcer.write().await; let mut max_generation = 0u64; let mut processed_keys = Vec::new(); for cmd in buf.drain(..) { match cmd { PolicyCommand::AddPolicies { policies, generation, subject_object_keys, response, } => { max_generation = max_generation.max(generation); processed_keys.extend(subject_object_keys); let result = async { enforcer .add_policies(policies) .await .map_err(|e| AppError::Internal(anyhow!("fail to add policy: {e:?}")))?; Ok(()) } .await; trace!("[access control v2]: AddPolicies result: {:?}", result); let _ = response.send(result); }, PolicyCommand::RemovePolicies { policies, generation, subject_object_keys, response, } => { max_generation = max_generation.max(generation); processed_keys.extend(subject_object_keys); let result = async { enforcer .remove_policies(policies) .await .map_err(|e| AppError::Internal(anyhow!("fail to remove policy: {e:?}")))?; Ok(()) } .await; trace!("[access control v2]: RemovePolicies result: {:?}", result); let _ = response.send(result); }, PolicyCommand::Shutdown => { trace!("[access control v2]: Policy update processor shutting down"); return; }, } } drop(enforcer); trace!("[access control v2]: Finished processing {} commands", n); // Update consistency tracking if max_generation > 0 { trace!( "[access control v2]: Updating processed generation from {} to {}", processed_generation.load(Ordering::SeqCst), max_generation ); processed_generation.store(max_generation, Ordering::SeqCst); if !processed_keys.is_empty() { let mut pending = pending_operations.write().await; for key in processed_keys { pending.remove(&key); } } // Notify waiters trace!( "[access control v2]: Notifying waiters after processing generation {}", max_generation ); generation_notify.notify_waiters(); } } } /// Send a command with metrics tracking async fn send_command_with_metrics(&self, cmd: PolicyCommand) -> Result<(), AppError> { // Increment send attempts self .metrics_state .policy_send_attempts .fetch_add(1, Ordering::Relaxed); // First try to send without blocking to detect if channel is full match self.policy_cmd_tx.try_send(cmd) { Ok(()) => { trace!("[access control v2]: Command sent successfully"); Ok(()) }, Err(mpsc::error::TrySendError::Full(cmd)) => { self .metrics_state .policy_channel_full_events .fetch_add(1, Ordering::Relaxed); warn!("[access control v2]: Policy channel is full, waiting to send..."); let send_timeout = Duration::from_secs(5); match timeout(send_timeout, self.policy_cmd_tx.send(cmd)).await { Ok(Ok(())) => { trace!("[access control v2]: Command sent successfully after waiting"); Ok(()) }, Ok(Err(_)) => { self .metrics_state .policy_send_failures .fetch_add(1, Ordering::Relaxed); Err(AppError::Internal(anyhow!("Policy update channel closed"))) }, Err(_) => { self .metrics_state .policy_send_failures .fetch_add(1, Ordering::Relaxed); Err(AppError::Internal(anyhow!( "Policy update timed out after {} seconds - channel may be overloaded", send_timeout.as_secs() ))) }, } }, Err(mpsc::error::TrySendError::Closed(_)) => { self .metrics_state .policy_send_failures .fetch_add(1, Ordering::Relaxed); Err(AppError::Internal(anyhow!("Policy update channel closed"))) }, } } /// Update policy for a user using queue-based approach. /// This method will never cause a deadlock. #[instrument(level = "debug", skip_all, err)] pub async fn update_policy( &self, sub: SubjectType, obj: ObjectType, act: T, ) -> Result<(), AppError> where T: Acts, { let policies = act .policy_acts() .into_iter() .map(|act| vec![sub.policy_subject(), obj.policy_object(), act]) .collect::>>(); info!("[access control v2]: queuing add policy:{:?}", policies); // Generate new generation number let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1; // Extract subject-object keys let subject = sub.policy_subject(); let object = obj.policy_object(); let subject_object_keys = vec![(subject, object)]; // Add to pending operations { let mut pending = self.pending_operations.write().await; for key in &subject_object_keys { pending.insert(key.clone()); } } let (tx, rx) = tokio::sync::oneshot::channel(); self .send_command_with_metrics(PolicyCommand::AddPolicies { policies, generation, subject_object_keys, response: tx, }) .await?; let result = rx .await .map_err(|_| AppError::Internal(anyhow!("Policy update response dropped")))?; trace!( "[access control v2]: Received policy update response: {:?}", result ); result } /// Remove policies for a subject and object type. pub async fn remove_policy( &self, sub: SubjectType, object_type: ObjectType, ) -> Result<(), AppError> { // First, get the policies to remove by reading let policies_for_user_on_object = { let enforcer = self.enforcer.read().await; policies_for_subject_with_given_object(sub.clone(), object_type.clone(), &enforcer).await }; event!( tracing::Level::INFO, "[access control v2]: queuing remove policy:subject={}, object={}, policies={:?}", sub.policy_subject(), object_type.policy_object(), policies_for_user_on_object ); if policies_for_user_on_object.is_empty() { return Ok(()); } // Generate new generation number let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1; let subject = sub.policy_subject(); let object = object_type.policy_object(); let subject_object_keys = vec![(subject, object)]; // Add to pending operations { let mut pending = self.pending_operations.write().await; for key in &subject_object_keys { pending.insert(key.clone()); } } let (tx, rx) = tokio::sync::oneshot::channel(); self .send_command_with_metrics(PolicyCommand::RemovePolicies { policies: policies_for_user_on_object, generation, subject_object_keys, response: tx, }) .await?; let result = rx .await .map_err(|_| AppError::Internal(anyhow!("Policy update response dropped")))?; trace!( "[access control v2]: Received policy removal response: {:?}", result ); result } /// Enforces an access control policy with eventual consistency. /// - `Eventual`: Returns immediately with potentially stale data (fastest) #[instrument(level = "debug", skip_all)] pub async fn enforce_policy( &self, uid: &i64, obj: ObjectType, act: T, ) -> Result where T: Acts, { self .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Eventual) .await } /// Enforces an access control policy with configurable consistency guarantees. /// - `Eventual`: Returns immediately with potentially stale data (fastest) /// - `Strong`: Waits for all pending updates before checking (most consistent) /// - `BoundedStrong`: Waits only for updates affecting this subject/object pair (balanced) #[instrument(level = "debug", skip_all)] pub async fn enforce_policy_with_consistency( &self, uid: &i64, obj: ObjectType, act: T, consistency: ConsistencyMode, ) -> Result where T: Acts, { self .metrics_state .total_read_enforce_result .fetch_add(1, Ordering::Relaxed); match consistency { ConsistencyMode::Eventual => { // No waiting, proceed immediately }, ConsistencyMode::Strong => { // Wait for all pending operations to complete let current_gen = self.generation.load(Ordering::Acquire); self.wait_for_generation(current_gen, None).await?; }, ConsistencyMode::BoundedStrong { timeout_ms } => { // Check if there are pending operations for this subject/object let subject = uid.to_string(); let object = obj.policy_object(); let key = (subject, object); let has_pending = { let pending = self.pending_operations.read().await; pending.contains(&key) }; if has_pending { // Wait for those specific operations to complete let current_gen = self.generation.load(Ordering::Acquire); self .wait_for_generation(current_gen, Some(Duration::from_millis(timeout_ms))) .await?; } }, } let policy_request = PolicyRequest::new(*uid, obj, act); let policy = policy_request.to_policy(); let result = self .enforcer .read() .await .enforce(policy) .map_err(|e| AppError::Internal(anyhow!("enforce: {e:?}")))?; Ok(result) } /// Wait for a specific generation to be processed async fn wait_for_generation( &self, target_generation: u64, timeout_duration: Option, ) -> Result<(), AppError> { let timeout_duration = timeout_duration.unwrap_or(Duration::from_secs(5)); let wait_future = async { loop { let notified = self.generation_notify.notified(); let processed = self.processed_generation.load(Ordering::Acquire); if processed >= target_generation { return Ok(()); } notified.await; } }; match timeout(timeout_duration, wait_future).await { Ok(result) => result, Err(_) => { error!( "[access control v2]: target_generation={}, current_generation={}, pending_operation={}", target_generation, self.processed_generation.load(Ordering::Acquire), self.pending_operations.read().await.len(), ); Err(AppError::Internal(anyhow!( "Timed out waiting for policy consistency after {}ms", timeout_duration.as_millis() ))) }, } } pub async fn shutdown(&self) -> Result<(), AppError> { self .send_command_with_metrics(PolicyCommand::Shutdown) .await?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::{ act::Action, casbin::access::{casbin_model, cmp_role_or_level}, entity::{ObjectType, SubjectType}, }; use casbin::{function_map::OperatorFunction, prelude::*}; use database_entity::dto::AFRole; use std::sync::Arc; use tokio::sync::Barrier; async fn test_enforcer_v2() -> AFEnforcerV2 { let model = casbin_model().await.unwrap(); let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default()) .await .unwrap(); enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); AFEnforcerV2::new(enforcer).await.unwrap() } #[tokio::test] async fn test_v2_no_deadlock_scenario() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 1; let workspace_id = "v2_test_workspace"; // This would deadlock in V1, but works fine in V2 let enforcer_clone = Arc::clone(&enforcer); // Update policy and immediately enforce multiple times enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Member, ) .await .unwrap(); // Can immediately enforce without any deadlock risk let can_read = enforcer_clone .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), Action::Read, ) .await .unwrap(); assert!(can_read, "User should be able to read"); // Clean up enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_concurrent_operations() { let enforcer = Arc::new(test_enforcer_v2().await); let barrier = Arc::new(Barrier::new(20)); let mut handles = Vec::new(); // Mix of concurrent updates and enforces for i in 0..20 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let uid = 1000 + i; let workspace_id = format!("workspace_{}", i % 5); if i % 2 == 0 { // Update policy enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.clone()), AFRole::Member, ) .await .expect("Failed to update policy"); } // Always try to enforce (may succeed or fail based on timing) let _ = enforcer_clone .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await; "Success" }); handles.push(handle); } // All operations should complete without deadlock for handle in handles { let result = handle.await.unwrap(); assert_eq!(result, "Success"); } // Clean up enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_queue_ordering() { let enforcer = test_enforcer_v2().await; let uid = 2000; let workspace_id = "order_test"; // Rapid sequence of operations enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await .unwrap(); enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); // Should end up with Guest role let has_guest = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); assert!(has_guest); let has_owner = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); assert!(!has_owner); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_queue_capacity_limits() { // Set a very small channel capacity via environment variable std::env::set_var("ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY", "2"); let enforcer = Arc::new(test_enforcer_v2().await); let barrier = Arc::new(Barrier::new(10)); let mut handles = Vec::new(); // Try to send many commands at once to fill the queue for i in 0..10 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let handle = tokio::spawn(async move { barrier_clone.wait().await; let uid = 3000 + i; let workspace_id = format!("capacity_test_{}", i); // This might block or timeout if queue is full let result = enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await; result.is_ok() }); handles.push(handle); } let mut success_count = 0; for handle in handles { if handle.await.unwrap() { success_count += 1; } } // All should eventually succeed since the processor is draining the queue assert_eq!( success_count, 10, "All operations should eventually succeed" ); // Check metrics for channel full events let channel_full_events = enforcer .metrics_state .policy_channel_full_events .load(Ordering::Relaxed); assert!( channel_full_events > 0, "Should have experienced channel full events with small capacity" ); // Clean up enforcer.shutdown().await.unwrap(); std::env::remove_var("ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY"); } #[tokio::test] async fn test_v2_policy_query_consistency_during_rapid_updates() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 4000; let workspace_id = "consistency_test"; // This test demonstrates that rapid updates can lead to multiple policies // In a real application, you would typically remove the old role before adding a new one // Start with a clean slate - remove any existing policies let _ = enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await; // Do sequential updates with proper cleanup to ensure consistency for i in 0..10 { let role = if i % 2 == 0 { AFRole::Owner } else { AFRole::Guest }; // Remove existing policy first let _ = enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await; // Then add new policy enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), role, ) .await .unwrap(); } // Small delay to ensure queue is processed tokio::time::sleep(Duration::from_millis(50)).await; // Final state should be consistent - check which role is actually set let has_owner = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); let has_guest = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); println!( "After sequential updates with cleanup: has_owner={}, has_guest={}", has_owner, has_guest ); // Now we should have exactly one role let role_count = if has_owner { 1 } else { 0 } + if has_guest { 1 } else { 0 }; assert_eq!( role_count, 1, "Should have exactly one role active after proper updates, but found {} roles", role_count ); // The last update was Guest (i=9, odd number) assert!( !has_owner && has_guest, "Should have Guest role as the final state" ); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_multiple_policies_accumulation() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 4100; let workspace_id = "accumulation_test"; // Start clean let _ = enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await; // Add multiple roles without cleanup - demonstrates accumulation enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); // Small delay to ensure policies are applied tokio::time::sleep(Duration::from_millis(50)).await; // Check both roles let has_owner = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); let has_guest = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); // Both roles should be active due to accumulation assert!( has_owner && has_guest, "Both roles should be active when added without cleanup" ); // User should have highest permission (can delete) let can_delete = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), Action::Delete, ) .await .unwrap(); assert!( can_delete, "User should be able to delete with Owner role active" ); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_shutdown_with_pending_operations() { let enforcer = Arc::new(test_enforcer_v2().await); // Send many operations let mut handles = Vec::new(); for i in 0..50 { let enforcer_clone = Arc::clone(&enforcer); let handle = tokio::spawn(async move { let uid = 5000 + i; let workspace_id = format!("shutdown_test_{}", i); enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await }); handles.push(handle); } // Immediately shutdown tokio::time::sleep(Duration::from_millis(10)).await; enforcer.shutdown().await.unwrap(); // Operations might fail after shutdown let mut success_count = 0; let mut failure_count = 0; for handle in handles { match handle.await.unwrap() { Ok(_) => success_count += 1, Err(_) => failure_count += 1, } } println!( "Shutdown test: {} succeeded, {} failed", success_count, failure_count ); // At least some operations should have succeeded before shutdown assert!( success_count > 0, "Some operations should succeed before shutdown" ); } #[tokio::test] async fn test_v2_concurrent_read_write_race_conditions() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 6000; let workspace_id = "race_test"; // Initial setup enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Guest, ) .await .unwrap(); let barrier = Arc::new(Barrier::new(40)); let mut handles = Vec::new(); for i in 0..1000 { let enforcer_clone = Arc::clone(&enforcer); let barrier_clone = Arc::clone(&barrier); let ws_id = workspace_id.to_string(); let handle = tokio::spawn(async move { barrier_clone.wait().await; if i % 2 == 0 { // Writer: alternate between Owner and Member let role = if (i / 2) % 2 == 0 { AFRole::Owner } else { AFRole::Member }; enforcer_clone .update_policy(SubjectType::User(uid), ObjectType::Workspace(ws_id), role) .await .map(|_| format!("Write {}", i)) } else { // Reader: check current permissions let can_delete = enforcer_clone .enforce_policy_with_consistency( &uid, ObjectType::Workspace(ws_id), Action::Delete, ConsistencyMode::Strong, ) .await?; Ok(format!("Read {}: can_delete={}", i, can_delete)) } }); handles.push(handle); } // Collect all results let mut results = Vec::new(); for handle in handles { match handle.await.unwrap() { Ok(result) => results.push(result), Err(e) => panic!("Operation failed: {:?}", e), } } // All operations should complete successfully assert_eq!(results.len(), 1000, "All operations should complete"); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_policy_removal_during_enforcement() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 7000; let workspace_id = "removal_race_test"; // Setup initial permission enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); let barrier = Arc::new(Barrier::new(3)); // Task 1: Check permission let enforcer1 = Arc::clone(&enforcer); let barrier1 = Arc::clone(&barrier); let ws_id1 = workspace_id.to_string(); let check_handle = tokio::spawn(async move { barrier1.wait().await; // Check multiple times to increase chance of race let mut results = Vec::new(); for _ in 0..10 { let can_delete = enforcer1 .enforce_policy(&uid, ObjectType::Workspace(ws_id1.clone()), Action::Delete) .await .unwrap(); results.push(can_delete); tokio::time::sleep(Duration::from_micros(100)).await; } results }); // Task 2: Remove permission let enforcer2 = Arc::clone(&enforcer); let barrier2 = Arc::clone(&barrier); let ws_id2 = workspace_id.to_string(); let remove_handle = tokio::spawn(async move { barrier2.wait().await; // Small delay to let some checks happen first tokio::time::sleep(Duration::from_millis(2)).await; enforcer2 .remove_policy(SubjectType::User(uid), ObjectType::Workspace(ws_id2)) .await .unwrap(); }); // Task 3: Re-add with different permission let enforcer3 = Arc::clone(&enforcer); let barrier3 = Arc::clone(&barrier); let ws_id3 = workspace_id.to_string(); let readd_handle = tokio::spawn(async move { barrier3.wait().await; // Delay to happen after removal tokio::time::sleep(Duration::from_millis(5)).await; enforcer3 .update_policy( SubjectType::User(uid), ObjectType::Workspace(ws_id3), AFRole::Guest, ) .await .unwrap(); }); // Wait for all tasks let check_results = check_handle.await.unwrap(); remove_handle.await.unwrap(); readd_handle.await.unwrap(); // Results should transition from true to false println!( "Permission check results during removal: {:?}", check_results ); // Early checks should be true, later ones false assert!(check_results[0], "Initial checks should show permission"); assert!( !check_results[check_results.len() - 1], "Final checks should show no delete permission (Guest role)" ); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_high_throughput_mixed_operations() { let enforcer = Arc::new(test_enforcer_v2().await); let operation_count = 1000; let user_count = 10; let workspace_count = 5; let start_time = std::time::Instant::now(); let mut handles = Vec::new(); for i in 0..operation_count { let enforcer_clone = Arc::clone(&enforcer); let handle = tokio::spawn(async move { let uid = 8000 + (i % user_count); let workspace_id = format!("high_throughput_ws_{}", i % workspace_count); match i % 4 { 0 => { // Add policy enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await .map(|_| "add") }, 1 => { // Check permission enforcer_clone .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .map(|_| "check") }, 2 => { // Update policy enforcer_clone .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Owner, ) .await .map(|_| "update") }, _ => { // Remove policy enforcer_clone .remove_policy(SubjectType::User(uid), ObjectType::Workspace(workspace_id)) .await .map(|_| "remove") }, } }); handles.push(handle); } // Wait for all operations let mut success_count = 0; for handle in handles { if handle.await.unwrap().is_ok() { success_count += 1; } } let elapsed = start_time.elapsed(); let ops_per_sec = (operation_count as f64) / elapsed.as_secs_f64(); println!( "High throughput test: {} operations in {:?} ({:.0} ops/sec)", operation_count, elapsed, ops_per_sec ); assert_eq!( success_count, operation_count, "All operations should succeed" ); assert!(ops_per_sec > 100.0, "Should handle at least 100 ops/sec"); // Check metrics let total_attempts = enforcer .metrics_state .policy_send_attempts .load(Ordering::Relaxed); let total_failures = enforcer .metrics_state .policy_send_failures .load(Ordering::Relaxed); println!( "Metrics: {} attempts, {} failures", total_attempts, total_failures ); assert_eq!(total_failures, 0, "Should have no send failures"); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_empty_policy_removal() { let enforcer = test_enforcer_v2().await; let uid = 9000; let workspace_id = "empty_removal_test"; // Try to remove non-existent policy let result = enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await; // Should succeed even with no policies to remove assert!( result.is_ok(), "Removing non-existent policy should not error" ); enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_policy_enforcement_accuracy() { let enforcer = test_enforcer_v2().await; let uid = 10000; let workspace_id = "accuracy_test"; // Test different role levels and their permissions // Based on load_group_policies in access.rs: // - Owner: can Delete, Write, Read // - Member: can Write, Read // - Guest: can Write, Read let test_cases = vec![ ( AFRole::Owner, vec![Action::Read, Action::Write, Action::Delete], vec![true, true, true], ), ( AFRole::Member, vec![Action::Read, Action::Write, Action::Delete], vec![true, true, false], ), ( AFRole::Guest, vec![Action::Read, Action::Write, Action::Delete], vec![true, true, false], ), // Guest CAN write! ]; for (role, actions, expected_results) in test_cases { // Clean slate - remove any existing policies let _ = enforcer .remove_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), ) .await; // Set role enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), role.clone(), ) .await .unwrap(); // Small delay to ensure policy is applied tokio::time::sleep(Duration::from_millis(10)).await; // Check each action for (action, expected) in actions.into_iter().zip(expected_results) { let result = enforcer .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), action.clone(), ) .await .unwrap(); assert_eq!( result, expected, "Role {:?} with action {:?} should be {}", role, action, expected ); } } enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_consistency_modes() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 11000; let workspace_id = "consistency_mode_test"; // Test 1: Eventual consistency (default) - might return stale result let enforcer1 = Arc::clone(&enforcer); let eventual_result = tokio::spawn(async move { // Update and immediately check with eventual consistency enforcer1 .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); // This might return false if policy hasn't been processed yet enforcer1 .enforce_policy( &uid, ObjectType::Workspace(workspace_id.to_string()), Action::Delete, ) .await .unwrap() }); // Test 2: Strong consistency - always returns correct result let enforcer2 = Arc::clone(&enforcer); let strong_result = tokio::spawn(async move { // Update and check with strong consistency enforcer2 .update_policy( SubjectType::User(uid + 1), ObjectType::Workspace(format!("{}_2", workspace_id)), AFRole::Owner, ) .await .unwrap(); // This will always return true because it waits for the update enforcer2 .enforce_policy_with_consistency( &(uid + 1), ObjectType::Workspace(format!("{}_2", workspace_id)), Action::Delete, ConsistencyMode::Strong, ) .await .unwrap() }); // Test 3: Bounded strong consistency - waits only for relevant updates let enforcer3 = Arc::clone(&enforcer); let bounded_result = tokio::spawn(async move { // Update policy enforcer3 .update_policy( SubjectType::User(uid + 2), ObjectType::Workspace(format!("{}_3", workspace_id)), AFRole::Member, ) .await .unwrap(); // Check with bounded consistency (waits up to 1 second) enforcer3 .enforce_policy_with_consistency( &(uid + 2), ObjectType::Workspace(format!("{}_3", workspace_id)), Action::Write, ConsistencyMode::BoundedStrong { timeout_ms: 1000 }, ) .await .unwrap() }); let _ = eventual_result.await.unwrap(); let strong = strong_result.await.unwrap(); let bounded = bounded_result.await.unwrap(); // Strong consistency should always work assert!(strong, "Strong consistency should guarantee correct result"); assert!(bounded, "Bounded consistency should work within timeout"); // Note: eventual_result might be true or false depending on timing enforcer.shutdown().await.unwrap(); } #[tokio::test] async fn test_v2_consistency_timeout() { let enforcer = Arc::new(test_enforcer_v2().await); let uid = 12000; let workspace_id = "timeout_test"; // Create a scenario where processor is slow by filling the channel for i in 0..100 { let _ = enforcer .update_policy( SubjectType::User(uid + i), ObjectType::Workspace(format!("{}_{}", workspace_id, i)), AFRole::Member, ) .await; } // Try to enforce with very short timeout let _result = enforcer .enforce_policy_with_consistency( &uid, ObjectType::Workspace(workspace_id.to_string()), Action::Read, ConsistencyMode::BoundedStrong { timeout_ms: 1 }, // 1ms timeout ) .await; // Should likely timeout (unless processor is very fast) // Note: This is not a guarantee, just likely enforcer.shutdown().await.unwrap(); } } ================================================ FILE: libs/access-control/src/casbin/mod.rs ================================================ pub mod access; mod adapter; pub mod collab; #[cfg(test)] mod enforcer; pub mod enforcer_v2; #[cfg(test)] mod performance_comparison_tests; mod redis_cache; mod util; pub mod workspace; ================================================ FILE: libs/access-control/src/casbin/performance_comparison_tests.rs ================================================ /// Performance comparison tests between AFEnforcer V1 and V2 /// === Baseline enforce_policy Performance (No Concurrent Writes) === /// Test configuration: 10,000 enforce_policy calls /// /// | Version | Total Time | Avg per Op | Speed Ratio | /// |---------|------------|------------|-------------| /// | V1 | 54.644ms | 5.46μs | 1.00x | /// | V2 | 46.127ms | 4.61μs | 1.18x | /// ok /// test casbin::performance_comparison_tests::performance_tests::test_enforce_policy_latency_distribution ... /// === enforce_policy Latency Distribution === /// Test configuration: 1000 individual operation latency measurements /// /// | Version | P50 (μs) | P95 (μs) | P99 (μs) | Max (μs) | /// |---------|----------|----------|----------|----------| /// | V1 | 6 | 201 | 313 | 1103 | /// | V2 | 4 | 78 | 221 | 251 | /// ok /// test casbin::performance_comparison_tests::performance_tests::test_enforce_policy_under_write_load ... /// === enforce_policy Performance Under Heavy Write Load === /// Test configuration: /// - 4 reader threads × 1000 reads = 4000 total reads /// - 2 writer threads × 100 writes = 200 total writes /// /// | Version | Avg Read Time | Speed Ratio | /// |---------|---------------|-------------| /// | V1 | 8.50ms | 1.00x | /// | V2 | 140.00ms | 0.06x | /// ok /// test casbin::performance_comparison_tests::performance_tests::test_mixed_workload_throughput ... /// === Mixed Workload Throughput Test === /// Test configuration: /// - Duration: 2 seconds /// - Concurrent threads: 8 /// - Workload mix: 90% reads, 10% writes /// /// | Version | Total Ops | Throughput | Speed Ratio | /// |---------|-----------|------------|-------------| /// | V1 | 2216 | 1108 ops/s | 1.00x | /// | V2 | 11331 | 5666 ops/s | 5.11x | /// /// cargo test -p access-control -- --ignored performance_tests --nocapture #[cfg(test)] mod performance_tests { use crate::{ act::Action, casbin::{ access::{casbin_model, cmp_role_or_level}, enforcer::AFEnforcer, enforcer_v2::AFEnforcerV2, }, entity::{ObjectType, SubjectType}, }; use casbin::{function_map::OperatorFunction, prelude::*}; use database_entity::dto::AFRole; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Barrier; /// Helper to create V1 enforcer async fn create_v1_enforcer() -> AFEnforcer { let model = casbin_model().await.unwrap(); let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default()) .await .unwrap(); enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); AFEnforcer::new(enforcer).await.unwrap() } /// Helper to create V2 enforcer async fn create_v2_enforcer() -> AFEnforcerV2 { let model = casbin_model().await.unwrap(); let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default()) .await .unwrap(); enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); AFEnforcerV2::new(enforcer).await.unwrap() } /// Test 1: Baseline Performance /// /// This test measures pure enforce_policy performance without any concurrent writes. /// It establishes a baseline to show that V2 has minimal overhead even in the simplest case. /// /// Expected Results: /// - V1: ~6-7μs per operation /// - V2: ~4-5μs per operation /// - V2 should be 1.3-1.5x faster /// /// Why V2 is faster even without contention: /// - No retry logic needed (V1's retry_write adds overhead) /// - Simpler code path for reads #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore] async fn test_enforce_policy_baseline_performance() { println!("\n=== Baseline enforce_policy Performance (No Concurrent Writes) ==="); println!("Test configuration: 10,000 enforce_policy calls\n"); // Setup V1 let v1_enforcer = Arc::new(create_v1_enforcer().await); for i in 0..100 { v1_enforcer .update_policy( SubjectType::User(i), ObjectType::Workspace(format!("workspace_{}", i)), AFRole::Member, ) .await .unwrap(); } // Setup V2 let v2_enforcer = Arc::new(create_v2_enforcer().await); for i in 0..100 { v2_enforcer .update_policy( SubjectType::User(i), ObjectType::Workspace(format!("workspace_{}", i)), AFRole::Member, ) .await .unwrap(); } // Wait for V2 background task to complete tokio::time::sleep(Duration::from_millis(100)).await; // Measure V1 performance let iterations = 10000; let start = Instant::now(); for i in 0..iterations { let uid = (i % 100) as i64; let workspace_id = format!("workspace_{}", uid); let _ = v1_enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); } let v1_duration = start.elapsed(); let v1_avg = v1_duration.as_micros() as f64 / iterations as f64; // Measure V2 performance let start = Instant::now(); for i in 0..iterations { let uid = (i % 100) as i64; let workspace_id = format!("workspace_{}", uid); let _ = v2_enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); } let v2_duration = start.elapsed(); let v2_avg = v2_duration.as_micros() as f64 / iterations as f64; println!("| Version | Total Time | Avg per Op | Speed Ratio |"); println!("|---------|------------|------------|-------------|"); println!( "| V1 | {:>10.3?} | {:>8.2}μs | 1.00x |", v1_duration, v1_avg ); println!( "| V2 | {:>10.3?} | {:>8.2}μs | {:.2}x |", v2_duration, v2_avg, v1_avg / v2_avg ); // Cleanup v2_enforcer.shutdown().await.unwrap(); } /// Test 2: Performance Under Write Load /// /// This test measures how reads perform when there are concurrent writes happening. /// This is where V2's architecture should shine - reads should never be blocked by writes. /// /// Test setup: /// - 4 reader threads doing 1000 reads each /// - 2 writer threads doing 100 writes each /// /// Expected behavior: /// - V1: Readers will be blocked when writers hold the lock /// - V2: Readers continue unimpeded while writes are queued /// /// Note: The test showing V2 slower might be due to: /// - Test setup issues (too many policies being created) /// - Background task falling behind /// - Need to tune queue size for heavy write loads #[tokio::test(flavor = "multi_thread", worker_threads = 6)] #[ignore] async fn test_enforce_policy_under_write_load() { println!("\n=== enforce_policy Performance Under Heavy Write Load ==="); println!("Test configuration:"); println!("- 4 reader threads × 1000 reads = 4000 total reads"); println!("- 2 writer threads × 100 writes = 200 total writes\n"); let v1_enforcer = Arc::new(create_v1_enforcer().await); let v2_enforcer = Arc::new(create_v2_enforcer().await); // Test parameters let read_threads = 4; let write_threads = 2; let reads_per_thread = 1000; let writes_per_thread = 100; // V1 Test let barrier = Arc::new(Barrier::new(read_threads + write_threads)); let mut handles = Vec::new(); // Spawn reader threads for thread_id in 0..read_threads { let enforcer = Arc::clone(&v1_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); for i in 0..reads_per_thread { let uid = ((thread_id * 1000 + i) % 100) as i64; let workspace_id = format!("workspace_{}", uid); let _ = enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); } start.elapsed() })); } // Spawn writer threads for thread_id in 0..write_threads { let enforcer = Arc::clone(&v1_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); for i in 0..writes_per_thread { let uid = (thread_id * 1000 + i) as i64; let workspace_id = format!("workspace_write_{}", i); enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await .unwrap(); } start.elapsed() })); } // Collect V1 results let mut v1_read_times = Vec::new(); let mut v1_write_times = Vec::new(); for (idx, handle) in handles.into_iter().enumerate() { let duration = handle.await.unwrap(); if idx < read_threads { v1_read_times.push(duration); } else { v1_write_times.push(duration); } } // V2 Test let barrier = Arc::new(Barrier::new(read_threads + write_threads)); let mut handles = Vec::new(); // Spawn reader threads for thread_id in 0..read_threads { let enforcer = Arc::clone(&v2_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); for i in 0..reads_per_thread { let uid = ((thread_id * 1000 + i) % 100) as i64; let workspace_id = format!("workspace_{}", uid); let _ = enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); } start.elapsed() })); } // Spawn writer threads for thread_id in 0..write_threads { let enforcer = Arc::clone(&v2_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); for i in 0..writes_per_thread { let uid = (thread_id * 1000 + i) as i64; let workspace_id = format!("workspace_write_{}", i); enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(workspace_id), AFRole::Member, ) .await .unwrap(); } start.elapsed() })); } // Collect V2 results let mut v2_read_times = Vec::new(); let mut v2_write_times = Vec::new(); for (idx, handle) in handles.into_iter().enumerate() { let duration = handle.await.unwrap(); if idx < read_threads { v2_read_times.push(duration); } else { v2_write_times.push(duration); } } // Print results let v1_avg_read = v1_read_times.iter().map(|d| d.as_millis()).sum::() as f64 / v1_read_times.len() as f64; let v2_avg_read = v2_read_times.iter().map(|d| d.as_millis()).sum::() as f64 / v2_read_times.len() as f64; println!("| Version | Avg Read Time | Speed Ratio |"); println!("|---------|---------------|-------------|"); println!("| V1 | {:>11.2}ms | 1.00x |", v1_avg_read); println!( "| V2 | {:>11.2}ms | {:.2}x |", v2_avg_read, v1_avg_read / v2_avg_read ); // Cleanup v2_enforcer.shutdown().await.unwrap(); } /// Test 3: Latency Distribution /// /// This test measures the distribution of latencies to understand consistency. /// While average performance is important, tail latencies (P95, P99) are critical /// for user experience - users notice the slowest requests. /// /// Expected Results: /// - P50 (median): Should be similar for both /// - P95/P99: V2 should be significantly better /// - Max: V2 should avoid the multi-millisecond spikes V1 can have /// /// From actual runs: /// - V1 max: 4,343μs (4.3ms!) - clear evidence of lock contention /// - V2 max: 202μs - much more predictable #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore] async fn test_enforce_policy_latency_distribution() { println!("\n=== enforce_policy Latency Distribution ==="); println!("Test configuration: 1000 individual operation latency measurements\n"); let v1_enforcer = Arc::new(create_v1_enforcer().await); let v2_enforcer = Arc::new(create_v2_enforcer().await); // Pre-populate some policies for i in 0..50 { v1_enforcer .update_policy( SubjectType::User(i), ObjectType::Workspace(format!("workspace_{}", i)), AFRole::Member, ) .await .unwrap(); v2_enforcer .update_policy( SubjectType::User(i), ObjectType::Workspace(format!("workspace_{}", i)), AFRole::Member, ) .await .unwrap(); } // Wait for V2 to process tokio::time::sleep(Duration::from_millis(50)).await; // Measure individual operation latencies let samples = 1000; let mut v1_latencies = Vec::with_capacity(samples); let mut v2_latencies = Vec::with_capacity(samples); // V1 measurements for i in 0..samples { let uid = (i % 50) as i64; let workspace_id = format!("workspace_{}", uid); let start = Instant::now(); let _ = v1_enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); v1_latencies.push(start.elapsed().as_micros()); } // V2 measurements for i in 0..samples { let uid = (i % 50) as i64; let workspace_id = format!("workspace_{}", uid); let start = Instant::now(); let _ = v2_enforcer .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read) .await .unwrap(); v2_latencies.push(start.elapsed().as_micros()); } // Calculate percentiles v1_latencies.sort(); v2_latencies.sort(); let p50_idx = samples / 2; let p95_idx = (samples * 95) / 100; let p99_idx = (samples * 99) / 100; println!("| Version | P50 (μs) | P95 (μs) | P99 (μs) | Max (μs) |"); println!("|---------|----------|----------|----------|----------|"); println!( "| V1 | {:>8} | {:>8} | {:>8} | {:>8} |", v1_latencies[p50_idx], v1_latencies[p95_idx], v1_latencies[p99_idx], v1_latencies[samples - 1] ); println!( "| V2 | {:>8} | {:>8} | {:>8} | {:>8} |", v2_latencies[p50_idx], v2_latencies[p95_idx], v2_latencies[p99_idx], v2_latencies[samples - 1] ); // Cleanup v2_enforcer.shutdown().await.unwrap(); } /// Test 4: Mixed Workload Throughput (Most Important Test) /// /// This test simulates a realistic workload with 90% reads and 10% writes, /// measuring total system throughput. This is the most representative test /// of real-world performance. /// /// Why 90/10 split? /// - Most access control systems are read-heavy /// - Users check permissions far more often than permissions change /// /// Expected Results: /// - V2 should show even greater advantage with fewer writes /// /// This test clearly shows V2's advantage in production workloads. #[tokio::test(flavor = "multi_thread", worker_threads = 8)] #[ignore] async fn test_mixed_workload_throughput() { println!("\n=== Mixed Workload Throughput Test ==="); println!("Test configuration:"); println!("- Duration: 2 seconds"); println!("- Concurrent threads: 8"); println!("- Workload mix: 90% reads, 10% writes\n"); let v1_enforcer = Arc::new(create_v1_enforcer().await); let v2_enforcer = Arc::new(create_v2_enforcer().await); let duration = Duration::from_secs(2); let threads = 8; // V1 throughput test let barrier = Arc::new(Barrier::new(threads)); let mut handles = Vec::new(); for thread_id in 0..threads { let enforcer = Arc::clone(&v1_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); let mut operations = 0u64; let mut counter = 0u64; while start.elapsed() < duration { counter += 1; if counter % 10 == 0 { // 10% writes let uid = (thread_id * 1000 + counter as usize) as i64; enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(format!("workspace_{}", counter)), AFRole::Member, ) .await .unwrap(); } else { // 90% reads let uid = (counter % 100) as i64; let _ = enforcer .enforce_policy( &uid, ObjectType::Workspace(format!("workspace_{}", uid)), Action::Read, ) .await .unwrap(); } operations += 1; } operations })); } let mut v1_total_ops = 0u64; for handle in handles { v1_total_ops += handle.await.unwrap(); } // V2 throughput test let barrier = Arc::new(Barrier::new(threads)); let mut handles = Vec::new(); for thread_id in 0..threads { let enforcer = Arc::clone(&v2_enforcer); let barrier_clone = Arc::clone(&barrier); handles.push(tokio::spawn(async move { barrier_clone.wait().await; let start = Instant::now(); let mut operations = 0u64; let mut counter = 0u64; while start.elapsed() < duration { counter += 1; if counter % 10 == 0 { // 10% writes let uid = (thread_id * 1000 + counter as usize) as i64; enforcer .update_policy( SubjectType::User(uid), ObjectType::Workspace(format!("workspace_{}", counter)), AFRole::Member, ) .await .unwrap(); } else { // 90% reads let uid = (counter % 100) as i64; let _ = enforcer .enforce_policy( &uid, ObjectType::Workspace(format!("workspace_{}", uid)), Action::Read, ) .await .unwrap(); } operations += 1; } operations })); } let mut v2_total_ops = 0u64; for handle in handles { v2_total_ops += handle.await.unwrap(); } let v1_throughput = v1_total_ops as f64 / duration.as_secs_f64(); let v2_throughput = v2_total_ops as f64 / duration.as_secs_f64(); println!("| Version | Total Ops | Throughput | Speed Ratio |"); println!("|---------|-----------|------------|-------------|"); println!( "| V1 | {:>9} | {:>7.0} ops/s | 1.00x |", v1_total_ops, v1_throughput ); println!( "| V2 | {:>9} | {:>7.0} ops/s | {:.2}x |", v2_total_ops, v2_throughput, v2_throughput / v1_throughput ); // Cleanup v2_enforcer.shutdown().await.unwrap(); } } ================================================ FILE: libs/access-control/src/casbin/redis_cache.rs ================================================ use casbin::Cache; use redis::{Client, Connection, IntoConnectionInfo, RedisResult}; use serde::{de::DeserializeOwned, Serialize}; use std::{hash::Hash, marker::PhantomData, sync::Mutex}; const CACHE_HKEY: &str = "ac:cache:v1"; const CACHE_TTL_SECONDS: usize = 600; // 10 minutes pub struct RedisCache { conn: Mutex, _marker: PhantomData<(K, V)>, } impl RedisCache where K: Eq + Hash + Clone, { pub fn new(redis_url: T) -> RedisResult> { let client = Client::open(redis_url)?; let conn = client.get_connection()?; Ok(RedisCache { conn: Mutex::new(conn), _marker: PhantomData, }) } } impl Cache for RedisCache where K: Eq + Hash + Send + Sync + Serialize + Clone + 'static, V: Send + Sync + Clone + Serialize + DeserializeOwned + 'static, { fn get(&self, k: &K) -> Option { // Get from Redis if let Ok(field) = serde_json::to_string(&k) { if let Ok(mut conn) = self.conn.lock() { if let Ok(res) = redis::cmd("HGET") .arg(CACHE_HKEY) .arg(&field) .query::>(&mut *conn) { if let Some(data) = res.as_ref().and_then(|d| serde_json::from_str::(d).ok()) { return Some(data); } } } } None } fn has(&self, k: &K) -> bool { if let Ok(field) = serde_json::to_string(&k) { if let Ok(mut conn) = self.conn.lock() { if let Ok(res) = redis::cmd("HEXISTS") .arg(CACHE_HKEY) .arg(&field) .query::(&mut *conn) { return res; } } } false } fn set(&self, k: K, v: V) { if let Ok(mut conn) = self.conn.lock() { if let (Ok(field), Ok(value)) = (serde_json::to_string(&k), serde_json::to_string(&v)) { let script = r#" redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 1 "#; let _ = redis::Script::new(script) .key(CACHE_HKEY) .arg(&field) .arg(&value) .arg(CACHE_TTL_SECONDS) .invoke::<()>(&mut *conn); } } } fn clear(&self) { // Clear Redis if let Ok(mut conn) = self.conn.lock() { let _ = redis::cmd("DEL").arg(CACHE_HKEY).query::(&mut *conn); } } } #[cfg(test)] mod tests { use super::*; #[test] #[ignore] fn test_set_has_get_clear() { // Skip test if Redis is not available let cache = match RedisCache::new("redis://localhost:6379") { Ok(cache) => cache, Err(_) => { println!("Redis not available, skipping test"); return; }, }; let cache: RedisCache, bool> = cache; // Clear any existing cache cache.clear(); // Test set and has cache.set(vec!["alice", "/data1", "read"], false); assert!(cache.has(&vec!["alice", "/data1", "read"])); // Test get assert_eq!(cache.get(&vec!["alice", "/data1", "read"]), Some(false)); // Test clear cache.clear(); assert_eq!(cache.get(&vec!["alice", "/data1", "read"]), None); } #[test] #[ignore] fn test_ttl_expiration() { let cache = match RedisCache::new("redis://localhost:6379") { Ok(cache) => cache, Err(_) => { println!("Redis not available, skipping test"); return; }, }; let cache: RedisCache = cache; cache.clear(); // Set a value cache.set("test_key".to_string(), "test_value".to_string()); // Verify it exists assert_eq!( cache.get(&"test_key".to_string()), Some("test_value".to_string()) ); } } ================================================ FILE: libs/access-control/src/casbin/util.rs ================================================ use crate::casbin::access::{POLICY_FIELD_INDEX_OBJECT, POLICY_FIELD_INDEX_SUBJECT}; use crate::entity::{ObjectType, SubjectType}; use casbin::{CachedEnforcer, MgmtApi}; #[inline] pub(crate) async fn policies_for_subject_with_given_object( subject: SubjectType, object_type: ObjectType, enforcer: &CachedEnforcer, ) -> Vec> { let subject_id = subject.policy_subject(); let object_type_id = object_type.policy_object(); let policies_related_to_object = enforcer.get_filtered_policy(POLICY_FIELD_INDEX_OBJECT, vec![object_type_id]); policies_related_to_object .into_iter() .filter(|p| p[POLICY_FIELD_INDEX_SUBJECT] == subject_id) .collect::>() } #[cfg(test)] pub mod tests { use crate::casbin::access::{casbin_model, cmp_role_or_level}; use crate::casbin::enforcer_v2::AFEnforcerV2; use casbin::function_map::OperatorFunction; use casbin::{CoreApi, MemoryAdapter}; pub async fn test_enforcer_v2() -> AFEnforcerV2 { let model = casbin_model().await.unwrap(); let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default()) .await .unwrap(); enforcer.add_function("cmpRoleOrLevel", OperatorFunction::Arg2(cmp_role_or_level)); AFEnforcerV2::new(enforcer).await.unwrap() } } ================================================ FILE: libs/access-control/src/casbin/workspace.rs ================================================ use async_trait::async_trait; use tracing::instrument; use uuid::Uuid; use super::access::AccessControl; use crate::act::Action; use crate::entity::{ObjectType, SubjectType}; use crate::workspace::WorkspaceAccessControl; use app_error::AppError; use database_entity::dto::AFRole; #[derive(Clone)] pub struct WorkspaceAccessControlImpl { access_control: AccessControl, } impl WorkspaceAccessControlImpl { pub fn new(access_control: AccessControl) -> Self { Self { access_control } } } #[async_trait] impl WorkspaceAccessControl for WorkspaceAccessControlImpl { async fn enforce_role_strong( &self, uid: &i64, workspace_id: &Uuid, role: AFRole, ) -> Result<(), AppError> { let result = self .access_control .enforce_strong(uid, ObjectType::Workspace(workspace_id.to_string()), role) .await; match result { Ok(true) => Ok(()), Ok(false) => Err(AppError::NotEnoughPermissions), Err(e) => Err(e), } } async fn enforce_role_weak( &self, uid: &i64, workspace_id: &Uuid, role: AFRole, ) -> Result<(), AppError> { let result = self .access_control .enforce_weak(uid, ObjectType::Workspace(workspace_id.to_string()), role) .await; match result { Ok(true) => Ok(()), Ok(false) => Err(AppError::NotEnoughPermissions), Err(e) => Err(e), } } async fn enforce_action( &self, uid: &i64, workspace_id: &Uuid, action: Action, ) -> Result<(), AppError> { let result = self .access_control .enforce_immediately(uid, ObjectType::Workspace(workspace_id.to_string()), action) .await; match result { Ok(true) => Ok(()), Ok(false) => Err(AppError::NotEnoughPermissions), Err(e) => Err(e), } } #[instrument(level = "info", skip_all)] async fn insert_role( &self, uid: &i64, workspace_id: &Uuid, role: AFRole, ) -> Result<(), AppError> { self .access_control .update_policy( SubjectType::User(*uid), ObjectType::Workspace(workspace_id.to_string()), role, ) .await?; Ok(()) } #[instrument(level = "info", skip_all)] async fn remove_user_from_workspace( &self, uid: &i64, workspace_id: &Uuid, ) -> Result<(), AppError> { self .access_control .remove_policy( SubjectType::User(*uid), ObjectType::Workspace(workspace_id.to_string()), ) .await?; self .access_control .remove_policy( SubjectType::User(*uid), ObjectType::Collab(workspace_id.to_string()), ) .await?; Ok(()) } } #[cfg(test)] mod tests { use app_error::ErrorCode; use database_entity::dto::AFRole; use uuid::Uuid; use crate::casbin::util::tests::test_enforcer_v2; use crate::{ casbin::access::AccessControl, entity::{ObjectType, SubjectType}, workspace::WorkspaceAccessControl, }; #[tokio::test] pub async fn test_workspace_access_control() { let enforcer = test_enforcer_v2().await; let member_uid = 1; let owner_uid = 2; let workspace_id = Uuid::new_v4(); enforcer .update_policy( SubjectType::User(member_uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Member, ) .await .unwrap(); enforcer .update_policy( SubjectType::User(owner_uid), ObjectType::Workspace(workspace_id.to_string()), AFRole::Owner, ) .await .unwrap(); let access_control = AccessControl::with_enforcer(enforcer); let workspace_access_control = super::WorkspaceAccessControlImpl::new(access_control); for uid in [member_uid, owner_uid] { workspace_access_control .enforce_role_strong(&uid, &workspace_id, AFRole::Member) .await .unwrap_or_else(|_| panic!("Failed to enforce role for {}", uid)); workspace_access_control .enforce_action(&uid, &workspace_id, crate::act::Action::Read) .await .unwrap_or_else(|_| panic!("Failed to enforce action for {}", uid)); } let result = workspace_access_control .enforce_action(&member_uid, &workspace_id, crate::act::Action::Delete) .await; let error_code = result.unwrap_err().code(); assert_eq!(error_code, ErrorCode::NotEnoughPermissions); workspace_access_control .enforce_action(&owner_uid, &workspace_id, crate::act::Action::Delete) .await .unwrap(); } } ================================================ FILE: libs/access-control/src/collab.rs ================================================ use crate::act::Action; use app_error::AppError; use async_trait::async_trait; use database_entity::dto::AFAccessLevel; use uuid::Uuid; #[async_trait] pub trait CollabAccessControl: Sync + Send + 'static { /// Check if the user can perform the action on the collab. /// Returns AppError::NotEnoughPermission if the user does not have the permission. async fn enforce_action( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, action: Action, ) -> Result<(), AppError>; /// Check if the user has the access level in the collab. /// Returns AppError::NotEnoughPermission if the user does not have the access level. async fn enforce_access_level( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, access_level: AFAccessLevel, ) -> Result<(), AppError>; /// Return the access level of the user in the collab async fn update_access_level_policy( &self, uid: &i64, oid: &Uuid, level: AFAccessLevel, ) -> Result<(), AppError>; async fn remove_access_level(&self, uid: &i64, oid: &Uuid) -> Result<(), AppError>; } #[async_trait] pub trait RealtimeAccessControl: Sync + Send + 'static { /// Return true if the user is allowed to edit collab. /// This function will be called very frequently, so it should be very fast. /// /// The user can send the message if: /// 1. user is the member of the collab object /// 2. the permission level of the user is `ReadAndWrite` or `FullAccess` /// 3. If the collab object is not found which means the collab object is created by the user. async fn can_write_collab( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, ) -> Result; /// Return true if the user is allowed to observe the changes of given collab. /// This function will be called very frequently, so it should be very fast. /// /// The user can recv the message if the user is the member of the collab object async fn can_read_collab( &self, workspace_id: &Uuid, uid: &i64, oid: &Uuid, ) -> Result; } ================================================ FILE: libs/access-control/src/entity.rs ================================================ #[derive(Debug, Clone)] pub enum SubjectType { User(i64), Group(String), } impl SubjectType { pub fn policy_subject(&self) -> String { match self { SubjectType::User(i) => i.to_string(), SubjectType::Group(s) => s.clone(), } } } /// Represents the object type that is stored in the access control policy. #[derive(Debug, Clone)] pub enum ObjectType { /// Stored as `workspace::` Workspace(String), /// Stored as `collab::` Collab(String), } impl ObjectType { pub fn policy_object(&self) -> String { match self { ObjectType::Collab(s) => format!("collab::{}", s), ObjectType::Workspace(s) => format!("workspace::{}", s), } } pub fn object_id(&self) -> String { match self { ObjectType::Collab(s) => s.clone(), ObjectType::Workspace(s) => s.clone(), } } } ================================================ FILE: libs/access-control/src/lib.rs ================================================ pub mod act; #[cfg(feature = "casbin")] pub mod casbin; pub mod collab; pub mod entity; pub mod metrics; pub mod noops; mod request; pub mod workspace; ================================================ FILE: libs/access-control/src/metrics.rs ================================================ use prometheus_client::metrics::gauge::Gauge; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; use std::time::Duration; use prometheus_client::registry::Registry; use tokio::time::interval; pub const ENFORCER_METRICS_TICK_INTERVAL: Duration = Duration::from_secs(120); #[derive(Clone)] pub struct AccessControlMetrics { load_all_policies: Gauge, total_read_enforce_count: Gauge, read_enforce_from_cache_count: Gauge, policy_send_attempts: Gauge, policy_send_failures: Gauge, policy_channel_full_events: Gauge, } impl AccessControlMetrics { pub(crate) fn init() -> Self { Self { load_all_policies: Gauge::default(), total_read_enforce_count: Gauge::default(), read_enforce_from_cache_count: Gauge::default(), policy_send_attempts: Gauge::default(), policy_send_failures: Gauge::default(), policy_channel_full_events: Gauge::default(), } } pub fn register(registry: &mut Registry) -> Self { let metrics = Self::init(); let realtime_registry = registry.sub_registry_with_prefix("ac"); realtime_registry.register( "load_all_polices", "load all polices when server start duration in milliseconds", metrics.load_all_policies.clone(), ); realtime_registry.register( "total_read_enforce_count", "total read enforce count", metrics.total_read_enforce_count.clone(), ); realtime_registry.register( "read_enforce_from_cache_count", "read enforce result from cache", metrics.read_enforce_from_cache_count.clone(), ); realtime_registry.register( "policy_send_attempts", "total policy channel send attempts", metrics.policy_send_attempts.clone(), ); realtime_registry.register( "policy_send_failures", "policy channel send failures (timeout or closed)", metrics.policy_send_failures.clone(), ); realtime_registry.register( "policy_channel_full_events", "times policy channel was full (would block)", metrics.policy_channel_full_events.clone(), ); metrics } pub fn record_load_all_policies_in_ms(&self, millis: u64) { self.load_all_policies.set(millis as i64); } pub fn record_enforce_count(&self, total: i64, from_cache: i64) { self.total_read_enforce_count.set(total); self.read_enforce_from_cache_count.set(from_cache); } pub fn record_policy_send_metrics(&self, attempts: i64, failures: i64, channel_full: i64) { self.policy_send_attempts.set(attempts); self.policy_send_failures.set(failures); self.policy_channel_full_events.set(channel_full); } } #[derive(Clone)] pub(crate) struct MetricsCalState { pub(crate) total_read_enforce_result: Arc, pub(crate) read_enforce_result_from_cache: Arc, pub(crate) policy_send_attempts: Arc, pub(crate) policy_send_failures: Arc, pub(crate) policy_channel_full_events: Arc, } impl MetricsCalState { pub(crate) fn new() -> Self { Self { total_read_enforce_result: Arc::new(Default::default()), read_enforce_result_from_cache: Arc::new(Default::default()), policy_send_attempts: Arc::new(Default::default()), policy_send_failures: Arc::new(Default::default()), policy_channel_full_events: Arc::new(Default::default()), } } } /// Collect and record metrics for access control pub(crate) fn tick_metric(state: MetricsCalState, metrics: Arc) { tokio::spawn(async move { let mut interval = interval(ENFORCER_METRICS_TICK_INTERVAL); loop { interval.tick().await; metrics.record_enforce_count( state.total_read_enforce_result.load(Ordering::Relaxed), state.read_enforce_result_from_cache.load(Ordering::Relaxed), ); metrics.record_policy_send_metrics( state.policy_send_attempts.load(Ordering::Relaxed), state.policy_send_failures.load(Ordering::Relaxed), state.policy_channel_full_events.load(Ordering::Relaxed), ); } }); } ================================================ FILE: libs/access-control/src/noops/collab.rs ================================================ use app_error::AppError; use async_trait::async_trait; use database_entity::dto::AFAccessLevel; use uuid::Uuid; use crate::{ act::Action, collab::{CollabAccessControl, RealtimeAccessControl}, }; #[derive(Clone)] pub struct CollabAccessControlImpl; impl CollabAccessControlImpl { pub fn new() -> Self { Self {} } } impl Default for CollabAccessControlImpl { fn default() -> Self { Self::new() } } #[async_trait] impl CollabAccessControl for CollabAccessControlImpl { async fn enforce_action( &self, _workspace_id: &Uuid, _uid: &i64, _oid: &Uuid, _action: Action, ) -> Result<(), AppError> { Ok(()) } async fn enforce_access_level( &self, _workspace_id: &Uuid, _uid: &i64, _oid: &Uuid, _access_level: AFAccessLevel, ) -> Result<(), AppError> { Ok(()) } async fn update_access_level_policy( &self, _uid: &i64, _oid: &Uuid, _level: AFAccessLevel, ) -> Result<(), AppError> { Ok(()) } async fn remove_access_level(&self, _uid: &i64, _oid: &Uuid) -> Result<(), AppError> { Ok(()) } } #[derive(Clone)] pub struct RealtimeCollabAccessControlImpl; impl RealtimeCollabAccessControlImpl { pub fn new() -> Self { Self {} } } impl Default for RealtimeCollabAccessControlImpl { fn default() -> Self { Self::new() } } #[async_trait] impl RealtimeAccessControl for RealtimeCollabAccessControlImpl { async fn can_write_collab( &self, _workspace_id: &Uuid, _uid: &i64, _oid: &Uuid, ) -> Result { Ok(true) } async fn can_read_collab( &self, _workspace_id: &Uuid, _uid: &i64, _oid: &Uuid, ) -> Result { Ok(true) } } ================================================ FILE: libs/access-control/src/noops/mod.rs ================================================ pub mod collab; pub mod workspace; ================================================ FILE: libs/access-control/src/noops/workspace.rs ================================================ use async_trait::async_trait; use uuid::Uuid; use crate::act::Action; use crate::workspace::WorkspaceAccessControl; use app_error::AppError; use database_entity::dto::AFRole; #[derive(Clone)] pub struct WorkspaceAccessControlImpl; impl WorkspaceAccessControlImpl { pub fn new() -> Self { Self {} } } impl Default for WorkspaceAccessControlImpl { fn default() -> Self { Self::new() } } #[async_trait] impl WorkspaceAccessControl for WorkspaceAccessControlImpl { async fn enforce_role_strong( &self, _uid: &i64, _workspace_id: &Uuid, _role: AFRole, ) -> Result<(), AppError> { Ok(()) } async fn enforce_role_weak( &self, _uid: &i64, _workspace_id: &Uuid, _role: AFRole, ) -> Result<(), AppError> { Ok(()) } async fn enforce_action( &self, _uid: &i64, _workspace_id: &Uuid, _action: Action, ) -> Result<(), AppError> { Ok(()) } async fn insert_role( &self, _uid: &i64, _workspace_id: &Uuid, _role: AFRole, ) -> Result<(), AppError> { Ok(()) } async fn remove_user_from_workspace( &self, _uid: &i64, _workspace_id: &Uuid, ) -> Result<(), AppError> { Ok(()) } } ================================================ FILE: libs/access-control/src/request.rs ================================================ use crate::act::Acts; use crate::entity::ObjectType; pub struct PolicyRequest { uid: i64, object_type: ObjectType, action_policy_string: String, } impl PolicyRequest { pub fn new(uid: i64, object_type: ObjectType, action: T) -> Self where T: Acts, { Self { uid, object_type, action_policy_string: action.to_enforce_act().to_string(), } } pub fn to_policy(&self) -> Vec { vec![ self.uid.to_string(), self.object_type.policy_object(), self.action_policy_string.clone(), ] } } ================================================ FILE: libs/access-control/src/workspace.rs ================================================ use crate::act::Action; use app_error::AppError; use async_trait::async_trait; use database_entity::dto::AFRole; use sqlx::types::Uuid; #[async_trait] pub trait WorkspaceAccessControl: Send + Sync + 'static { /// Check if the user has the role in the workspace. /// Returns AppError::NotEnoughPermission if the user does not have the role. async fn enforce_role_strong( &self, uid: &i64, workspace_id: &Uuid, role: AFRole, ) -> Result<(), AppError>; async fn enforce_role_weak( &self, uid: &i64, workspace_id: &Uuid, role: AFRole, ) -> Result<(), AppError>; /// Check if the user can perform action on the workspace. /// Returns AppError::NotEnoughPermission if the user does not have the role. async fn enforce_action( &self, uid: &i64, workspace_id: &Uuid, action: Action, ) -> Result<(), AppError>; async fn insert_role(&self, uid: &i64, workspace_id: &Uuid, role: AFRole) -> Result<(), AppError>; async fn remove_user_from_workspace( &self, uid: &i64, workspace_id: &Uuid, ) -> Result<(), AppError>; } ================================================ FILE: libs/app-error/Cargo.toml ================================================ [package] name = "app-error" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] crate-type = ["cdylib", "rlib"] [dependencies] thiserror = "1.0.56" serde_repr = "0.1.18" serde.workspace = true anyhow.workspace = true uuid = { workspace = true, features = ["v4"] } sqlx = { workspace = true, default-features = false, features = [ "postgres", "json", ], optional = true } validator = { workspace = true, optional = true } url = { version = "2.5.0" } actix-web = { version = "4.4.1", optional = true } reqwest.workspace = true serde_json.workspace = true tokio = { workspace = true, optional = true } bincode = { version = "1.3.3", optional = true } appflowy-ai-client = { workspace = true, optional = true, features = ["dto"] } async-openai = { workspace = true, optional = true } tokio-tungstenite = { workspace = true } [features] default = [] sqlx_error = ["sqlx"] validation_error = ["validator"] actix_web_error = ["actix-web"] tokio_error = ["tokio"] gotrue_error = [] bincode_error = ["bincode"] appflowy_ai_error = ["appflowy-ai-client", "async-openai"] [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2", features = ["js"] } tsify = "0.4.5" wasm-bindgen = "0.2.84" ================================================ FILE: libs/app-error/src/gotrue.rs ================================================ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; use thiserror::Error; #[derive(Debug, Error)] pub enum GoTrueError { #[error("connect error:{0}")] Connect(String), #[error("request timeout:{0}")] RequestTimeout(String), #[error("invalid request:{0}")] InvalidRequest(String), #[error(transparent)] ClientError(#[from] GotrueClientError), #[error(transparent)] Internal(#[from] GoTrueErrorSerde), #[error("{0}")] NotLoggedIn(String), #[error("{0}")] Auth(String), #[error(transparent)] Unhandled(#[from] anyhow::Error), } impl GoTrueError { pub fn is_network_error(&self) -> bool { matches!( self, GoTrueError::Connect(_) | GoTrueError::RequestTimeout(_) ) } } impl From for GoTrueError { fn from(value: reqwest::Error) -> Self { #[cfg(not(target_arch = "wasm32"))] if value.is_connect() { return GoTrueError::Connect(value.to_string()); } if value.is_timeout() { return GoTrueError::RequestTimeout(value.to_string()); } if value.is_request() { return GoTrueError::InvalidRequest(value.to_string()); } if let Some(status) = value.status() { if status == StatusCode::UNAUTHORIZED { return GoTrueError::Auth(value.to_string()); } } GoTrueError::Unhandled(value.into()) } } #[derive(Serialize, Deserialize, Debug, Error)] pub struct GoTrueErrorSerde { pub code: i64, pub msg: String, pub error_id: Option, } impl Display for GoTrueErrorSerde { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "code: {}, msg:{}, error_id: {:?}", self.code, self.msg, self.error_id )) } } /// The gotrue error definition: /// https://github.com/supabase/auth/blob/cc07b4aa2ace75d9c8e46ae5107dbabadf944e87/internal/models/errors.go#L65 /// Used to deserialize the response from the gotrue server #[derive(Serialize, Deserialize, Debug, Error)] pub struct GotrueClientError { pub error: Option, pub error_description: Option, pub msg: Option, } impl Display for GotrueClientError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "error: {:?}, error_description: {:?}, msg: {:?}", self.error, self.error_description, self.msg )) } } ================================================ FILE: libs/app-error/src/lib.rs ================================================ #[cfg(feature = "gotrue_error")] pub mod gotrue; #[cfg(feature = "gotrue_error")] use crate::gotrue::GoTrueError; #[cfg(feature = "appflowy_ai_error")] use appflowy_ai_client::error::AIError; use reqwest::StatusCode; use serde::Serialize; use std::error::Error; use std::string::FromUtf8Error; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error, Default)] pub enum AppError { #[error("Operation completed successfully.")] #[default] Ok, #[error(transparent)] Internal(#[from] anyhow::Error), #[error("An unhandled error occurred:{0}")] Unhandled(String), #[error("Record not found:{0}")] RecordNotFound(String), #[error("Record deleted:{0}")] RecordDeleted(String), #[error("Record already exist:{0}")] RecordAlreadyExists(String), #[error("Invalid email format:{0}")] InvalidEmail(String), #[error("Invalid password:{0}")] InvalidPassword(String), #[error("Invalid page data:{0}")] InvalidPageData(String), #[error("{0}")] OAuthError(String), #[error("{0}")] UserUnAuthorized(String), #[error("{0}")] UserAlreadyRegistered(String), #[error("Missing Payload:{0}")] MissingPayload(String), #[error("Database error:{0}")] DBError(String), #[error("Open Error:{0}")] OpenError(String), #[error("Invalid request:{0}")] InvalidRequest(String), #[error("Invalid OAuth Provider:{0}")] InvalidOAuthProvider(String), #[error("Not Logged In:{0}")] NotLoggedIn(String), #[error("User does not have permissions to execute this action")] NotEnoughPermissions, #[error("s3 response error:{0}")] S3ResponseError(String), #[error("Storage space not enough")] StorageSpaceNotEnough, #[error("Payload too large:{0}")] PayloadTooLarge(String), #[error(transparent)] UuidError(#[from] uuid::Error), #[error(transparent)] IOError(#[from] std::io::Error), #[cfg(feature = "sqlx_error")] #[error("{0}")] SqlxError(String), #[cfg(feature = "sqlx_error")] #[error("{desc}: {err}")] SqlxArgEncodingError { desc: String, err: Box, }, #[cfg(feature = "validation_error")] #[error(transparent)] ValidatorError(#[from] validator::ValidationErrors), #[error(transparent)] UrlError(#[from] url::ParseError), #[error(transparent)] SerdeError(#[from] serde_json::Error), #[error(transparent)] Utf8Error(#[from] FromUtf8Error), #[error("{0}")] Connect(String), #[error("{0}")] RequestTimeout(String), #[cfg(feature = "tokio_error")] #[error(transparent)] TokioJoinError(#[from] tokio::task::JoinError), #[cfg(feature = "bincode_error")] #[error(transparent)] BincodeError(#[from] bincode::Error), #[error("{0}")] NoRequiredData(String), #[error("{0}")] OverrideWithIncorrectData(String), #[error("{0}")] PublishNamespaceAlreadyTaken(String), #[error("{0}")] AIServiceUnavailable(String), #[error("{0}")] StringLengthLimitReached(String), #[error("{0}")] InvalidContentType(String), #[error("{0}")] InvalidPublishedOutline(String), #[error("{0}")] InvalidFolderView(String), #[error("{0}")] NotInviteeOfWorkspaceInvitation(String), #[error("{0}")] MissingView(String), #[error("{0}")] TooManyImportTask(String), #[error("There is existing access request for workspace {workspace_id} and view {view_id}")] AccessRequestAlreadyExists { workspace_id: Uuid, view_id: Uuid }, #[error("There is existing published view for workspace {workspace_id} with publish_name {publish_name}")] PublishNameAlreadyExists { workspace_id: Uuid, publish_name: String, }, #[error("There is an invalid character in the publish name: {character}")] PublishNameInvalidCharacter { character: char }, #[error("The publish name is too long, given length: {given_length}, max length: {max_length}")] PublishNameTooLong { given_length: usize, max_length: usize, }, #[error("There is an invalid character in the publish namespace: {character}")] CustomNamespaceInvalidCharacter { character: char }, #[error("{0}")] ServiceTemporaryUnavailable(String), #[error("Decode update error: {0}")] DecodeUpdateError(String), #[error("{0}")] ActionTimeout(String), #[error("Apply update error:{0}")] ApplyUpdateError(String), #[error("{0}")] InvalidBlock(String), #[error("{0}")] FeatureNotAvailable(String), #[error("unable to find invitation code")] InvalidInvitationCode, #[error("{0} is already a member of the workspace")] InvalidGuest(String), #[error("free plan workspace guest limit exceeded")] FreePlanGuestLimitExceeded, #[error("paid plan workspace guest limit exceeded")] PaidPlanGuestLimitExceeded, #[error("{0}")] RetryLater(anyhow::Error), } impl AppError { pub fn is_not_enough_permissions(&self) -> bool { matches!(self, AppError::NotEnoughPermissions) } pub fn is_record_not_found(&self) -> bool { matches!(self, AppError::RecordNotFound(_)) } pub fn is_network_error(&self) -> bool { matches!(self, AppError::Connect(_) | AppError::RequestTimeout(_)) } pub fn is_unauthorized(&self) -> bool { matches!(self, AppError::UserUnAuthorized(_)) } pub fn code(&self) -> ErrorCode { match self { AppError::Ok => ErrorCode::Ok, AppError::Unhandled(_) => ErrorCode::Unhandled, AppError::RecordNotFound(_) => ErrorCode::RecordNotFound, AppError::RecordAlreadyExists(_) => ErrorCode::RecordAlreadyExists, AppError::InvalidEmail(_) => ErrorCode::InvalidEmail, AppError::InvalidPassword(_) => ErrorCode::InvalidPassword, AppError::OAuthError(_) => ErrorCode::OAuthError, AppError::UserUnAuthorized(_) => ErrorCode::UserUnAuthorized, AppError::UserAlreadyRegistered(_) => ErrorCode::RecordAlreadyExists, AppError::MissingPayload(_) => ErrorCode::MissingPayload, AppError::DBError(_) => ErrorCode::DBError, AppError::OpenError(_) => ErrorCode::OpenError, AppError::InvalidOAuthProvider(_) => ErrorCode::InvalidOAuthProvider, AppError::InvalidRequest(_) => ErrorCode::InvalidRequest, AppError::NotLoggedIn(_) => ErrorCode::NotLoggedIn, AppError::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, AppError::StorageSpaceNotEnough => ErrorCode::StorageSpaceNotEnough, AppError::PayloadTooLarge(_) => ErrorCode::PayloadTooLarge, AppError::Internal(_) => ErrorCode::Internal, AppError::UuidError(_) => ErrorCode::UuidError, AppError::IOError(_) => ErrorCode::IOError, #[cfg(feature = "sqlx_error")] AppError::SqlxError(_) => ErrorCode::SqlxError, #[cfg(feature = "sqlx_error")] AppError::SqlxArgEncodingError { .. } => ErrorCode::SqlxArgEncodingError, #[cfg(feature = "validation_error")] AppError::ValidatorError(_) => ErrorCode::InvalidRequest, AppError::S3ResponseError(_) => ErrorCode::S3ResponseError, AppError::UrlError(_) => ErrorCode::InvalidUrl, AppError::SerdeError(_) => ErrorCode::SerdeError, AppError::Connect(_) => ErrorCode::NetworkError, AppError::RequestTimeout(_) => ErrorCode::RequestTimeout, #[cfg(feature = "tokio_error")] AppError::TokioJoinError(_) => ErrorCode::Internal, #[cfg(feature = "bincode_error")] AppError::BincodeError(_) => ErrorCode::Internal, AppError::NoRequiredData(_) => ErrorCode::NoRequiredData, AppError::OverrideWithIncorrectData(_) => ErrorCode::OverrideWithIncorrectData, AppError::Utf8Error(_) => ErrorCode::Internal, AppError::PublishNamespaceAlreadyTaken(_) => ErrorCode::PublishNamespaceAlreadyTaken, AppError::AIServiceUnavailable(_) => ErrorCode::AIServiceUnavailable, AppError::StringLengthLimitReached(_) => ErrorCode::StringLengthLimitReached, AppError::InvalidContentType(_) => ErrorCode::InvalidContentType, AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline, AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView, AppError::InvalidPageData(_) => ErrorCode::InvalidPageData, AppError::NotInviteeOfWorkspaceInvitation(_) => ErrorCode::NotInviteeOfWorkspaceInvitation, AppError::MissingView(_) => ErrorCode::MissingView, AppError::AccessRequestAlreadyExists { .. } => ErrorCode::AccessRequestAlreadyExists, AppError::TooManyImportTask(_) => ErrorCode::TooManyImportTask, AppError::PublishNameAlreadyExists { .. } => ErrorCode::PublishNameAlreadyExists, AppError::PublishNameInvalidCharacter { .. } => ErrorCode::PublishNameInvalidCharacter, AppError::PublishNameTooLong { .. } => ErrorCode::PublishNameTooLong, AppError::CustomNamespaceInvalidCharacter { .. } => { ErrorCode::CustomNamespaceInvalidCharacter }, AppError::ServiceTemporaryUnavailable(_) => ErrorCode::ServiceTemporaryUnavailable, AppError::DecodeUpdateError(_) => ErrorCode::DecodeUpdateError, AppError::ApplyUpdateError(_) => ErrorCode::ApplyUpdateError, AppError::ActionTimeout(_) => ErrorCode::ActionTimeout, AppError::InvalidBlock(_) => ErrorCode::InvalidBlock, AppError::FeatureNotAvailable(_) => ErrorCode::FeatureNotAvailable, AppError::InvalidInvitationCode => ErrorCode::InvalidInvitationCode, AppError::InvalidGuest(_) => ErrorCode::InvalidGuest, AppError::FreePlanGuestLimitExceeded => ErrorCode::FreePlanGuestLimitExceeded, AppError::PaidPlanGuestLimitExceeded => ErrorCode::PaidPlanGuestLimitExceeded, AppError::RecordDeleted(_) => ErrorCode::RecordDeleted, AppError::RetryLater(_) => ErrorCode::RetryLater, } } } impl From for AppError { fn from(error: reqwest::Error) -> Self { #[cfg(not(target_arch = "wasm32"))] if error.is_connect() { return AppError::Connect(error.to_string()); } if error.is_timeout() { return AppError::RequestTimeout(error.to_string()); } if error.is_request() { return AppError::ServiceTemporaryUnavailable(error.to_string()); } if let Some(cause) = error.source() { if cause .to_string() .contains("connection closed before message completed") { return AppError::ServiceTemporaryUnavailable(error.to_string()); } } // Handle request-related errors if let Some(status_code) = error.status() { if error.is_request() { match status_code { StatusCode::PAYLOAD_TOO_LARGE => { return AppError::PayloadTooLarge(error.to_string()); }, status_code if status_code.is_server_error() => { return AppError::ServiceTemporaryUnavailable(error.to_string()); }, _ => { return AppError::InvalidRequest(error.to_string()); }, } } } AppError::Internal(error.into()) } } #[cfg(feature = "sqlx_error")] impl From for AppError { fn from(value: sqlx::Error) -> Self { let msg = value.to_string(); match value { sqlx::Error::RowNotFound => { AppError::RecordNotFound(format!("Record not exist in db. {})", msg)) }, sqlx::Error::PoolTimedOut => AppError::ActionTimeout(value.to_string()), _ => AppError::SqlxError(msg), } } } #[cfg(feature = "gotrue_error")] impl From for AppError { fn from(err: crate::gotrue::GoTrueError) -> Self { match err { GoTrueError::Connect(msg) => AppError::Connect(msg), GoTrueError::RequestTimeout(msg) => AppError::RequestTimeout(msg), GoTrueError::InvalidRequest(msg) => AppError::InvalidRequest(msg), GoTrueError::ClientError(err) => AppError::OAuthError(err.to_string()), GoTrueError::Auth(err) => AppError::UserUnAuthorized(err), GoTrueError::Internal(err) => match (err.code, err.msg.as_str()) { (400, m) if m.starts_with("oauth error") => AppError::OAuthError(err.msg), (400, m) if m.starts_with("User already registered") => { AppError::UserAlreadyRegistered(err.msg) }, (401, _) => AppError::UserUnAuthorized(format!("{}:{}", err.code, err.msg)), (422, _) => AppError::InvalidRequest(err.msg), _ => AppError::OAuthError(err.msg), }, GoTrueError::Unhandled(err) => AppError::Internal(err), GoTrueError::NotLoggedIn(msg) => AppError::NotLoggedIn(msg), } } } impl From for AppError { fn from(err: String) -> Self { AppError::Unhandled(err) } } #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[derive( Eq, PartialEq, Copy, Debug, Clone, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Default, )] #[repr(i32)] pub enum ErrorCode { #[default] Ok = 0, Unhandled = -1, RecordNotFound = -2, RecordAlreadyExists = -3, RecordDeleted = -4, RetryLater = -5, InvalidEmail = 1001, InvalidPassword = 1002, OAuthError = 1003, MissingPayload = 1004, DBError = 1005, OpenError = 1006, InvalidUrl = 1007, InvalidRequest = 1008, InvalidOAuthProvider = 1009, NotLoggedIn = 1011, NotEnoughPermissions = 1012, StorageSpaceNotEnough = 1015, PayloadTooLarge = 1016, Internal = 1017, UuidError = 1018, IOError = 1019, #[cfg(feature = "sqlx_error")] SqlxError = 1020, S3ResponseError = 1021, SerdeError = 1022, NetworkError = 1023, UserUnAuthorized = 1024, NoRequiredData = 1025, WorkspaceLimitExceeded = 1026, WorkspaceMemberLimitExceeded = 1027, FileStorageLimitExceeded = 1028, OverrideWithIncorrectData = 1029, PublishNamespaceNotSet = 1030, PublishNamespaceAlreadyTaken = 1031, AIServiceUnavailable = 1032, AIResponseLimitExceeded = 1033, StringLengthLimitReached = 1034, #[cfg(feature = "sqlx_error")] SqlxArgEncodingError = 1035, InvalidContentType = 1036, SingleUploadLimitExceeded = 1037, AppleRevokeTokenError = 1038, InvalidPublishedOutline = 1039, InvalidFolderView = 1040, NotInviteeOfWorkspaceInvitation = 1041, MissingView = 1042, AccessRequestAlreadyExists = 1043, CustomNamespaceDisabled = 1044, CustomNamespaceDisallowed = 1045, TooManyImportTask = 1046, CustomNamespaceTooShort = 1047, CustomNamespaceTooLong = 1048, CustomNamespaceReserved = 1049, PublishNameAlreadyExists = 1050, PublishNameInvalidCharacter = 1051, PublishNameTooLong = 1052, CustomNamespaceInvalidCharacter = 1053, ServiceTemporaryUnavailable = 1054, DecodeUpdateError = 1055, ApplyUpdateError = 1056, ActionTimeout = 1057, AIImageResponseLimitExceeded = 1058, MailerError = 1059, LicenseError = 1060, AIMaxRequired = 1061, InvalidPageData = 1062, MemberNotFound = 1063, InvalidBlock = 1064, RequestTimeout = 1065, AIResponseError = 1066, FeatureNotAvailable = 1067, InvalidInvitationCode = 1068, InvalidGuest = 1069, FreePlanGuestLimitExceeded = 1070, PaidPlanGuestLimitExceeded = 1071, } impl ErrorCode { pub fn value(&self) -> i32 { *self as i32 } } #[derive(Serialize)] struct AppErrorSerde { code: ErrorCode, message: String, } impl From<&AppError> for AppErrorSerde { fn from(value: &AppError) -> Self { Self { code: value.code(), message: value.to_string(), } } } #[cfg(feature = "actix_web_error")] impl actix_web::error::ResponseError for AppError { fn status_code(&self) -> actix_web::http::StatusCode { actix_web::http::StatusCode::OK } fn error_response(&self) -> actix_web::HttpResponse { actix_web::HttpResponse::Ok().json(AppErrorSerde::from(self)) } } #[cfg(feature = "appflowy_ai_error")] impl From for AppError { fn from(err: AIError) -> Self { match err { AIError::Internal(err) => AppError::Internal(err), AIError::RequestTimeout(err) => AppError::RequestTimeout(err), AIError::PayloadTooLarge(err) => AppError::PayloadTooLarge(err), AIError::InvalidRequest(err) => AppError::InvalidRequest(err), AIError::SerdeError(err) => AppError::SerdeError(err), AIError::ServiceUnavailable(err) => AppError::AIServiceUnavailable(err), } } } #[cfg(feature = "appflowy_ai_error")] impl From for AppError { fn from(err: async_openai::error::OpenAIError) -> Self { match &err { async_openai::error::OpenAIError::Reqwest(e) => AppError::InvalidRequest(e.to_string()), async_openai::error::OpenAIError::ApiError(e) => AppError::InvalidRequest(e.to_string()), async_openai::error::OpenAIError::InvalidArgument(e) => { AppError::InvalidRequest(e.to_string()) }, _ => AppError::Internal(err.into()), } } } use tokio_tungstenite::tungstenite::Error as TungsteniteError; impl From for AppError { fn from(err: TungsteniteError) -> Self { match &err { TungsteniteError::Http(resp) => { let status = resp.status(); if status == StatusCode::UNAUTHORIZED.as_u16() || status == StatusCode::NOT_FOUND.as_u16() { AppError::UserUnAuthorized("Unauthorized websocket connection".to_string()) } else { AppError::Internal(err.into()) } }, _ => AppError::Internal(err.into()), } } } impl From for AppError { fn from(err: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue) -> Self { AppError::InvalidRequest(err.to_string()) } } ================================================ FILE: libs/appflowy-ai-client/Cargo.toml ================================================ [package] name = "appflowy-ai-client" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] reqwest = { workspace = true, features = [ "json", "rustls-tls", "cookies", "stream", ], optional = true } serde = { version = "1.0.199", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } thiserror = "1.0.58" anyhow.workspace = true tracing = { version = "0.1", optional = true } serde_repr = { version = "0.1", optional = true } futures = "0.3.30" bytes.workspace = true ureq = { version = "2.12.1", optional = true, features = ["json"] } uuid = { workspace = true, features = ["serde"] } [dev-dependencies] appflowy-ai-client = { path = ".", features = ["dto", "client-api"] } tokio = { version = "1.37.0", features = ["macros", "test-util"] } tracing-subscriber = { version = "0.3.18", features = [ "registry", "env-filter", "ansi", "json", ] } uuid = { workspace = true, features = ["v4"] } infra.workspace = true [features] default = ["client-api"] client-api = [ "dto", "reqwest", "serde", "serde_json", "tracing", "serde_repr", "infra/request_util", "ureq" ] dto = ["serde", "serde_json", "serde_repr"] ================================================ FILE: libs/appflowy-ai-client/src/client.rs ================================================ use crate::dto::{ CalculateSimilarityParams, ChatAnswer, ChatQuestion, CompleteTextParams, CreateChatContext, Document, LocalAIConfig, MessageData, ModelList, QuestionMetadata, RepeatedLocalAIPackage, RepeatedRelatedQuestion, ResponseFormat, SearchDocumentsRequest, SimilarityResponse, SummarizeRowResponse, TranslateRowData, TranslateRowResponse, }; use crate::error::AIError; use bytes::Bytes; use futures::{Stream, StreamExt}; use reqwest; use reqwest::{Method, RequestBuilder, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::borrow::Cow; use std::time::Duration; use tracing::{info, trace}; const AI_MODEL_HEADER_KEY: &str = "ai-model"; #[derive(Clone, Debug)] pub struct AppFlowyAIClient { async_client: reqwest::Client, url: String, } impl AppFlowyAIClient { pub fn new(url: &str) -> Self { info!("Creating AppFlowyAIClient with url: {}", url); let url = url.to_string(); let async_client = reqwest::Client::new(); Self { async_client, url } } pub async fn health_check(&self) -> Result<(), AIError> { let url = format!("{}/health", self.url); let resp = self.async_http_client(Method::GET, &url)?.send().await?; let text = resp.text().await?; info!("health response: {:?}", text); Ok(()) } pub async fn stream_completion_text( &self, params: CompleteTextParams, model: &str, ) -> Result>, AIError> { if params.text.is_empty() { return Err(AIError::InvalidRequest("Empty text".to_string())); } let url = format!("{}/completion/stream", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(¶ms) .send() .await?; AIResponse::<()>::stream_response(resp).await } pub async fn stream_completion_v2( &self, params: CompleteTextParams, model: &str, ) -> Result>, AIError> { if params.text.is_empty() { return Err(AIError::InvalidRequest("Empty text".to_string())); } let url = format!("{}/v2/completion/stream", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(¶ms) .send() .await?; AIResponse::<()>::stream_response(resp).await } pub async fn summarize_row( &self, params: &Map, model: &str, ) -> Result { if params.is_empty() { return Err(AIError::InvalidRequest("Empty content".to_string())); } let url = format!("{}/summarize_row", self.url); trace!("summarize_row url: {}", url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(params) .send() .await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn translate_row( &self, data: TranslateRowData, model: &str, ) -> Result { let url = format!("{}/translate_row", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(&data) .send() .await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn search_documents( &self, request: &SearchDocumentsRequest, ) -> Result, AIError> { let url = format!("{}/search", self.url); let resp = self .async_http_client(Method::GET, &url)? .query(&request) .send() .await?; AIResponse::>::from_reqwest_response(resp) .await? .into_data() } pub async fn create_chat_text_context(&self, context: CreateChatContext) -> Result<(), AIError> { let url = format!("{}/chat/context/text", self.url); let resp = self .async_http_client(Method::POST, &url)? .json(&context) .send() .await?; let _ = AIResponse::<()>::from_reqwest_response(resp).await?; Ok(()) } pub async fn send_question( &self, workspace_id: &str, chat_id: &str, question_id: i64, content: &str, model: &str, metadata: Option, ) -> Result { let json = ChatQuestion { chat_id: chat_id.to_string(), data: MessageData { content: content.to_string(), metadata, message_id: Some(question_id.to_string()), }, format: Default::default(), metadata: QuestionMetadata { workspace_id: workspace_id.to_string(), rag_ids: vec![], }, }; let url = format!("{}/chat/message", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(&json) .send() .await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn stream_question( &self, workspace_id: String, chat_id: &str, content: &str, metadata: Option, rag_ids: Vec, model: &str, ) -> Result>, AIError> { let json = ChatQuestion { chat_id: chat_id.to_string(), data: MessageData { content: content.to_string(), metadata, message_id: None, }, format: Default::default(), metadata: QuestionMetadata { workspace_id, rag_ids, }, }; let url = format!("{}/chat/message/stream", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .timeout(Duration::from_secs(30)) .json(&json) .send() .await?; AIResponse::<()>::stream_response(resp).await } #[allow(clippy::too_many_arguments)] pub async fn stream_question_v2( &self, workspace_id: String, chat_id: &str, question_id: i64, content: &str, metadata: Option, rag_ids: Vec, model: &str, ) -> Result>, AIError> { let json = ChatQuestion { chat_id: chat_id.to_string(), data: MessageData { content: content.to_string(), metadata, message_id: Some(question_id.to_string()), }, format: ResponseFormat::default(), metadata: QuestionMetadata { workspace_id, rag_ids, }, }; self.stream_question_v3(model, json, Some(30)).await } pub async fn stream_question_v3( &self, model: &str, question: ChatQuestion, timeout_secs: Option, ) -> Result>, AIError> { let url = format!("{}/v2/chat/message/stream", self.url); let resp = self .async_http_client(Method::POST, &url)? .header(AI_MODEL_HEADER_KEY, model) .json(&question) .timeout(Duration::from_secs(timeout_secs.unwrap_or(30))) .send() .await?; AIResponse::<()>::stream_response(resp).await } pub async fn get_related_question( &self, chat_id: &str, message_id: &i64, model: &str, ) -> Result { let url = format!("{}/chat/{chat_id}/{message_id}/related_question", self.url); let resp = self .async_http_client(Method::GET, &url)? .header(AI_MODEL_HEADER_KEY, model) .timeout(Duration::from_secs(30)) .send() .await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn regenerate_image(&self, source_metadata: Value) -> Result<(), AIError> { let url = format!("{}/chat/image/regenerate", self.url); let resp = self .async_http_client(Method::POST, &url)? .json(&source_metadata) .timeout(Duration::from_secs(30)) .send() .await?; AIResponse::<()>::from_reqwest_response(resp) .await? .into_data() } pub async fn get_local_ai_package( &self, platform: &str, ) -> Result { let url = format!("{}/local_ai/plugin?platform={platform}", self.url); let resp = self.async_http_client(Method::GET, &url)?.send().await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn get_local_ai_config( &self, platform: &str, app_version: Option, ) -> Result { // Start with the base URL including the platform parameter let mut url = format!("{}/local_ai/config?platform={}", self.url, platform); // If app_version is provided, append it as a query parameter if let Some(version) = app_version { url = format!("{}&app_version={}", url, version); } let resp = self.async_http_client(Method::GET, &url)?.send().await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn get_model_list(&self) -> Result { let url = format!("{}/model/list", self.url); let resp = self.async_http_client(Method::GET, &url)?.send().await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } pub async fn calculate_similarity( &self, params: CalculateSimilarityParams, ) -> Result { let url = format!("{}/similarity", self.url); let resp = self .async_http_client(Method::POST, &url)? .json(¶ms) .send() .await?; AIResponse::::from_reqwest_response(resp) .await? .into_data() } fn async_http_client(&self, method: Method, url: &str) -> Result { let request_builder = self.async_client.request(method, url); Ok(request_builder) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AIResponse { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, #[serde(default)] pub message: Cow<'static, str>, } impl AIResponse where T: DeserializeOwned + 'static, { pub async fn from_reqwest_response(resp: reqwest::Response) -> Result { let status_code = resp.status(); if !status_code.is_success() { let body = resp.text().await?; anyhow::bail!("error code: {}, {}", status_code, body) } let bytes = resp.bytes().await?; let resp = serde_json::from_slice(&bytes)?; Ok(resp) } pub fn from_ur_response(resp: ureq::Response) -> Result { let status_code = resp.status(); if status_code != 200 { let body = resp.into_string()?; anyhow::bail!("error code: {}, {}", status_code, body) } let resp = resp.into_json()?; Ok(resp) } pub fn into_data(self) -> Result { match self.data { None => Err(AIError::InvalidRequest("Empty payload".to_string())), Some(data) => Ok(data), } } pub async fn stream_response( resp: reqwest::Response, ) -> Result>, AIError> { let status_code = resp.status(); if status_code.is_server_error() { let body = resp.text().await?; return Err(AIError::ServiceUnavailable(body)); } if !status_code.is_success() { let body = resp.text().await?; return Err(AIError::InvalidRequest(body)); } let stream = resp .bytes_stream() .map(|item| item.map_err(|err| AIError::Internal(err.into()))); Ok(stream) } } impl From for AIError { fn from(error: reqwest::Error) -> Self { if error.is_connect() { return AIError::ServiceUnavailable(error.to_string()); } if error.is_timeout() { return AIError::RequestTimeout(error.to_string()); } // Handle request-related errors if let Some(status_code) = error.status() { if error.is_request() { match status_code { StatusCode::PAYLOAD_TOO_LARGE => { return AIError::PayloadTooLarge(error.to_string()); }, status_code if status_code.is_server_error() => { return AIError::ServiceUnavailable(error.to_string()); }, _ => { return AIError::InvalidRequest(format!("{:?}", error)); }, } } } AIError::Internal(error.into()) } } pub async fn collect_stream_text(stream: impl Stream>) -> String { let stream = stream.map(|item| { item.map(|bytes| { String::from_utf8(bytes.to_vec()) .map(|s| s.replace('\n', "")) .unwrap() }) }); let lines: Vec = stream.map(|message| message.unwrap()).collect().await; lines.join("") } ================================================ FILE: libs/appflowy-ai-client/src/dto.rs ================================================ use serde::{Deserialize, Serialize, Serializer}; use serde_json::json; use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::HashMap; use std::fmt::{Display, Formatter}; use uuid::Uuid; pub const STREAM_METADATA_KEY: &str = "0"; pub const STREAM_ANSWER_KEY: &str = "1"; pub const STREAM_IMAGE_KEY: &str = "2"; pub const STREAM_KEEP_ALIVE_KEY: &str = "3"; pub const STREAM_COMMENT_KEY: &str = "4"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SummarizeRowResponse { pub text: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ChatQuestionQuery { pub chat_id: String, pub question_id: i64, pub format: ResponseFormat, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ChatQuestion { pub chat_id: String, pub data: MessageData, #[serde(default)] pub format: ResponseFormat, pub metadata: QuestionMetadata, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct QuestionMetadata { pub workspace_id: String, pub rag_ids: Vec, } #[derive(Clone, Default, Debug, Serialize, Deserialize)] pub struct ResponseFormat { pub output_layout: OutputLayout, pub output_content: OutputContent, pub output_content_metadata: Option, } impl ResponseFormat { pub fn new() -> Self { Self::default() } } #[derive(Clone, Debug, Default, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] pub enum OutputLayout { Paragraph = 0, BulletList = 1, NumberedList = 2, SimpleTable = 3, #[default] Flex = 4, } #[derive(Clone, Debug, Default, Serialize_repr, Deserialize_repr, Eq, PartialEq)] #[repr(u8)] pub enum OutputContent { #[default] TEXT = 0, IMAGE = 1, RichTextImage = 2, } impl OutputContent { pub fn is_image(&self) -> bool { *self == OutputContent::IMAGE || *self == OutputContent::RichTextImage } } #[derive(Clone, Default, Debug, Serialize, Deserialize)] pub struct OutputContentMetadata { /// Custom prompt for image generation. #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_image_prompt: Option, /// The image model to use for generation (default: "dall-e-3"). #[serde(default = "default_image_model")] pub image_model: String, /// Size of the image (default: "256x256"). #[serde( default = "default_image_size", skip_serializing_if = "Option::is_none" )] pub size: Option, /// Quality of the image (default: "standard"). #[serde( default = "default_image_quality", skip_serializing_if = "Option::is_none" )] pub quality: Option, } // Default values for the fields fn default_image_model() -> String { "dall-e-3".to_string() } fn default_image_size() -> Option { Some("256x256".to_string()) } fn default_image_quality() -> Option { Some("standard".to_string()) } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MessageData { pub content: String, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(default)] pub message_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ChatAnswer { pub content: String, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RepeatedRelatedQuestion { pub message_id: i64, pub items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RelatedQuestion { pub content: String, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CompleteTextResponse { pub text: String, } #[derive(Clone, Debug, Serialize_repr, Deserialize_repr, Eq, PartialEq, Hash)] #[repr(u8)] pub enum CompletionType { ImproveWriting = 1, SpellingAndGrammar = 2, MakeShorter = 3, MakeLonger = 4, ContinueWriting = 5, Explain = 6, AskAI = 7, CustomPrompt = 8, } #[derive(Debug, Clone, Serialize)] pub struct SearchDocumentsRequest { #[serde(serialize_with = "serialize_workspaces")] pub workspaces: Vec, pub query: String, #[serde(skip_serializing_if = "Option::is_none")] pub result_count: Option, } #[allow(clippy::ptr_arg)] fn serialize_workspaces(workspaces: &Vec, serializer: S) -> Result where S: Serializer, { let workspaces = workspaces.join(","); serializer.serialize_str(&workspaces) } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Document { pub id: String, #[serde(rename = "type")] pub doc_type: CollabType, pub workspace_id: String, pub content: String, } #[repr(u8)] #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize_repr, Deserialize_repr)] pub enum CollabType { Document = 0, Database = 1, WorkspaceDatabase = 2, Folder = 3, DatabaseRow = 4, UserAwareness = 5, Unknown = 6, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TranslateRowParams { pub workspace_id: String, pub data: TranslateRowData, } /// Represents different types of content that can be used to summarize a database row. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TranslateRowData { pub cells: Vec, pub language: String, pub include_header: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TranslateItem { pub title: String, pub content: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct TranslateRowResponse { pub items: Vec>, } #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum EmbeddingModel { #[serde(rename = "text-embedding-3-small")] TextEmbedding3Small, #[serde(rename = "text-embedding-3-large")] TextEmbedding3Large, #[serde(rename = "text-embedding-ada-002")] TextEmbeddingAda002, } impl EmbeddingModel { /// Returns the default embedding model used in this system. /// /// This model is hardcoded and used to generate embeddings whose dimensions are /// reflected in the PostgreSQL database schema. Changing the default model may /// require a migration to create a new table with the appropriate dimensions. pub fn default_model() -> Self { EmbeddingModel::TextEmbedding3Small } pub fn supported_models() -> &'static [&'static str] { &[ "text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large", ] } pub fn max_token(&self) -> usize { match self { EmbeddingModel::TextEmbeddingAda002 => 8191, EmbeddingModel::TextEmbedding3Large => 8191, EmbeddingModel::TextEmbedding3Small => 8191, } } pub fn default_dimensions(&self) -> u32 { match self { EmbeddingModel::TextEmbeddingAda002 => 1536, EmbeddingModel::TextEmbedding3Large => 3072, EmbeddingModel::TextEmbedding3Small => 1536, } } pub fn name(&self) -> &'static str { match self { EmbeddingModel::TextEmbeddingAda002 => "text-embedding-ada-002", EmbeddingModel::TextEmbedding3Large => "text-embedding-3-large", EmbeddingModel::TextEmbedding3Small => "text-embedding-3-small", } } pub fn from_name(name: &str) -> Option { match name { "text-embedding-ada-002" => Some(EmbeddingModel::TextEmbeddingAda002), "text-embedding-3-large" => Some(EmbeddingModel::TextEmbedding3Large), "text-embedding-3-small" => Some(EmbeddingModel::TextEmbedding3Small), _ => None, } } } impl Display for EmbeddingModel { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { EmbeddingModel::TextEmbedding3Small => write!(f, "text-embedding-3-small"), EmbeddingModel::TextEmbedding3Large => write!(f, "text-embedding-3-large"), EmbeddingModel::TextEmbeddingAda002 => write!(f, "text-embedding-ada-002"), } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RepeatedLocalAIPackage(pub Vec); #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct AppFlowyOfflineAI { pub app_name: String, pub ai_plugin_name: String, pub version: String, pub url: String, pub etag: String, } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct LLMModel { pub llm_id: i64, pub provider: String, pub embedding_model: ModelInfo, pub chat_model: ModelInfo, } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct ModelInfo { pub name: String, pub file_name: String, pub file_size: i64, pub requirements: String, pub download_url: String, pub desc: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LocalAIConfig { pub models: Vec, pub plugin: AppFlowyOfflineAI, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AvailableModel { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ModelList { pub models: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateChatContext { pub chat_id: String, pub context_loader: String, pub content: String, pub chunk_size: i32, pub chunk_overlap: i32, pub metadata: serde_json::Value, } impl CreateChatContext { pub fn new(chat_id: String, context_loader: String, text: String) -> Self { CreateChatContext { chat_id, context_loader, content: text, chunk_size: 2000, chunk_overlap: 20, metadata: json!({}), } } pub fn with_metadata(mut self, metadata: T) -> Self { self.metadata = json!(metadata); self } } impl Display for CreateChatContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "Create Chat context: {{ chat_id: {}, content_type: {}, content size: {}, metadata: {:?} }}", self.chat_id, self.context_loader, self.content.len(), self.metadata )) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CustomPrompt { pub system: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CalculateSimilarityParams { pub workspace_id: Uuid, pub input: String, pub expected: String, pub use_embedding: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SimilarityResponse { pub score: f64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CompletionMessage { pub role: String, // human, ai, or system pub content: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CompletionMetadata { /// A unique identifier for the object. Object could be a document id. pub object_id: Uuid, /// The workspace identifier. /// /// This field must be provided when generating images. We use workspace ID to track image usage. pub workspace_id: Option, /// A list of relevant document IDs. /// /// When using completions for document-related tasks, this should include the document ID. /// In some cases, `object_id` may be the same as the document ID. pub rag_ids: Option>, /// For the AI completion feature (the AI writer), pass the conversation history as input. /// This history helps the AI understand the context of the conversation. #[serde(default, skip_serializing_if = "Option::is_none")] pub completion_history: Option>, /// When completion type is 'CustomPrompt', this field should be provided. #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_prompt: Option, /// The id of the prompt used for the completion #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub prompt_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CompleteTextParams { pub text: String, pub completion_type: Option, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(default)] pub format: ResponseFormat, } impl CompleteTextParams { pub fn new_with_completion_type( text: String, completion_type: CompletionType, metadata: Option, ) -> Self { Self { text, completion_type: Some(completion_type), metadata, format: Default::default(), } } } ================================================ FILE: libs/appflowy-ai-client/src/error.rs ================================================ #[derive(Debug, thiserror::Error)] pub enum AIError { #[error(transparent)] Internal(#[from] anyhow::Error), #[error("Request timeout:{0}")] RequestTimeout(String), #[error("Payload too large:{0}")] PayloadTooLarge(String), #[error("Invalid request:{0}")] InvalidRequest(String), #[error(transparent)] SerdeError(#[from] serde_json::Error), #[error("Service unavailable:{0}")] ServiceUnavailable(String), } ================================================ FILE: libs/appflowy-ai-client/src/lib.rs ================================================ #[cfg(feature = "client-api")] pub mod client; #[cfg(feature = "dto")] pub mod dto; pub mod error; ================================================ FILE: libs/appflowy-ai-client/tests/chat_test/completion_test.rs ================================================ use crate::appflowy_ai_client; use appflowy_ai_client::client::collect_stream_text; use appflowy_ai_client::dto::{ CompleteTextParams, CompletionMetadata, CompletionType, CustomPrompt, OutputContent, OutputLayout, ResponseFormat, }; #[tokio::test] async fn completion_explain_test() { let client = appflowy_ai_client(); let params = CompleteTextParams { text: "Snowboarding".to_string(), completion_type: Some(CompletionType::Explain), metadata: Some(CompletionMetadata { object_id: uuid::Uuid::new_v4(), workspace_id: Some(uuid::Uuid::new_v4()), rag_ids: None, completion_history: None, custom_prompt: None, prompt_id: None, }), format: ResponseFormat::default(), }; let stream = client .stream_completion_text(params, "gpt-4o-mini") .await .unwrap(); let text = collect_stream_text(stream).await; assert!(!text.is_empty()); } #[tokio::test] async fn completion_image_test() { let client = appflowy_ai_client(); let params = CompleteTextParams { text: "A yellow cat".to_string(), completion_type: Some(CompletionType::ImproveWriting), metadata: Some(CompletionMetadata { object_id: uuid::Uuid::new_v4(), workspace_id: Some(uuid::Uuid::new_v4()), rag_ids: None, completion_history: None, custom_prompt: None, prompt_id: None, }), format: ResponseFormat { output_content: OutputContent::IMAGE, ..Default::default() }, }; let stream = client .stream_completion_text(params, "gpt-4o-mini") .await .unwrap(); let text = collect_stream_text(stream).await; println!("{}", text); assert!(text.contains("http://localhost")); } #[tokio::test] async fn continue_writing_test() { let client = appflowy_ai_client(); let params = CompleteTextParams { text: "I feel hungry".to_string(), completion_type: Some(CompletionType::ImproveWriting), metadata: None, format: ResponseFormat { output_layout: OutputLayout::SimpleTable, ..Default::default() }, }; let stream = client .stream_completion_text(params, "gpt-4o-mini") .await .unwrap(); let text = collect_stream_text(stream).await; assert!(!text.is_empty()); println!("{}", text); } #[tokio::test] async fn make_text_shorter_text() { let client = appflowy_ai_client(); let params = CompleteTextParams { text: "I have an immense passion and deep-seated affection for Rust, a modern, multi-paradigm, high-performance programming language that I find incredibly satisfying to use due to its focus on safety, speed, and concurrency".to_string(), completion_type: Some(CompletionType::MakeShorter), metadata: None, format: ResponseFormat::default(), }; let stream = client .stream_completion_text(params, "gpt-4o-mini") .await .unwrap(); let text = collect_stream_text(stream).await; // the response would be something like: // I'm deeply passionate about Rust, a modern, high-performance programming language, due to its emphasis on safety, speed, and concurrency assert!(!text.is_empty()); println!("{}", text); } #[tokio::test] async fn custom_prompt_test() { let client = appflowy_ai_client(); let params = CompleteTextParams { text: "A yellow cat".to_string(), completion_type: Some(CompletionType::CustomPrompt), metadata: Some(CompletionMetadata { object_id: uuid::Uuid::new_v4(), workspace_id: Some(uuid::Uuid::new_v4()), rag_ids: None, completion_history: None, custom_prompt: Some(CustomPrompt { system: "You are a talented artist who excels at providing detailed, creative instructions on how to draw a picture".to_string(), }), prompt_id: None, }), format: Default::default(), }; let stream = client .stream_completion_text(params, "gpt-4o-mini") .await .unwrap(); let text = collect_stream_text(stream).await; println!("{}", text); } ================================================ FILE: libs/appflowy-ai-client/tests/chat_test/context_test.rs ================================================ use crate::appflowy_ai_client; use appflowy_ai_client::dto::CreateChatContext; #[tokio::test] async fn create_chat_context_test() { let client = appflowy_ai_client(); let chat_id = uuid::Uuid::new_v4().to_string(); let context = CreateChatContext { chat_id: chat_id.clone(), context_loader: "text".to_string(), content: "I have lived in the US for five years".to_string(), chunk_size: 1000, chunk_overlap: 20, metadata: Default::default(), }; client.create_chat_text_context(context).await.unwrap(); let resp = client .send_question( &uuid::Uuid::new_v4().to_string(), &chat_id, 1, "Where I live?", "gpt-4o-mini", None, ) .await .unwrap(); // response will be something like: // Based on the context you provided, you have lived in the US for five years. Therefore, it is likely that you currently live in the US assert!(!resp.content.is_empty()); } ================================================ FILE: libs/appflowy-ai-client/tests/chat_test/mod.rs ================================================ mod completion_test; mod context_test; mod model_config_test; mod qa_test; ================================================ FILE: libs/appflowy-ai-client/tests/chat_test/model_config_test.rs ================================================ use crate::appflowy_ai_client; #[tokio::test] async fn get_model_list_test() { let client = appflowy_ai_client(); let models = client.get_model_list().await.unwrap().models; assert!(models.len() >= 5, "models.len() = {}", models.len()); } ================================================ FILE: libs/appflowy-ai-client/tests/chat_test/qa_test.rs ================================================ use crate::appflowy_ai_client; #[tokio::test] async fn qa_test() { let client = appflowy_ai_client(); client.health_check().await.unwrap(); let chat_id = uuid::Uuid::new_v4().to_string(); let resp = client .send_question( &uuid::Uuid::new_v4().to_string(), &chat_id, 1, "I feel hungry", "gpt-4o", None, ) .await .unwrap(); assert!(!resp.content.is_empty()); let questions = client .get_related_question(&chat_id, &1, "gpt-4o-mini") .await .unwrap() .items; println!("questions: {:?}", questions); assert_eq!(questions.len(), 3) } #[tokio::test] async fn download_package_test() { let client = appflowy_ai_client(); let packages = client.get_local_ai_package("macos").await.unwrap(); assert!(!packages.0.is_empty()); println!("packages: {:?}", packages); } #[tokio::test] async fn get_local_ai_config_test() { let client = appflowy_ai_client(); let config = client .get_local_ai_config("macos", Some("0.6.10".to_string())) .await .unwrap(); assert!(!config.models.is_empty()); assert!(!config.models[0].embedding_model.download_url.is_empty()); assert!(!config.models[0].chat_model.download_url.is_empty()); assert!(!config.plugin.version.is_empty()); assert!(!config.plugin.url.is_empty()); println!("packages: {:?}", config); } ================================================ FILE: libs/appflowy-ai-client/tests/index_test/index_search_test.rs ================================================ use appflowy_ai_client::client::AppFlowyAIClient; use appflowy_ai_client::dto::{CollabType, Document, SearchDocumentsRequest}; #[tokio::test] async fn index_search() { let client = appflowy_ai_client(); client .index_documents(&[ Document { id: "test-doc1".to_string(), doc_type: CollabType::Document, workspace_id: "test-workspace1".to_string(), content: "Relevant. This is an important test document. It should appear in results." .to_string(), }, Document { id: "test-doc2".to_string(), doc_type: CollabType::Document, workspace_id: "test-workspace1".to_string(), content: "Irrelevant. This is an unimportant test document. It shouldn't appear in results." .to_string(), }, Document { id: "test-doc3".to_string(), doc_type: CollabType::Document, workspace_id: "test-workspace2".to_string(), content: "Irrelevant. This is an unimportant test document. It shouldn't appear in results." .to_string(), }, ]) .await .unwrap(); let docs = client .search_documents(&SearchDocumentsRequest { workspaces: vec!["test-workspace1".to_string()], query: "relevant".to_string(), result_count: Some(1), }) .await .unwrap(); assert_eq!(docs.len(), 1); assert_eq!(docs[0].id, "test-doc1".to_string()); } ================================================ FILE: libs/appflowy-ai-client/tests/index_test/mod.rs ================================================ mod index_search_test; ================================================ FILE: libs/appflowy-ai-client/tests/main.rs ================================================ use appflowy_ai_client::client::AppFlowyAIClient; use std::sync::Once; use tracing_subscriber::fmt::Subscriber; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; mod chat_test; mod row_test; // mod index_test; pub fn appflowy_ai_client() -> AppFlowyAIClient { setup_log(); AppFlowyAIClient::new("http://localhost:5001") } pub fn setup_log() { static START: Once = Once::new(); START.call_once(|| { let level = std::env::var("RUST_LOG").unwrap_or("trace".to_string()); let mut filters = vec![]; filters.push(format!("appflowy_ai_client={}", level)); std::env::set_var("RUST_LOG", filters.join(",")); let subscriber = Subscriber::builder() .with_ansi(true) .with_env_filter(EnvFilter::from_default_env()) .finish(); subscriber.try_init().unwrap(); }); } ================================================ FILE: libs/appflowy-ai-client/tests/row_test/mod.rs ================================================ mod summarize_test; mod translate_test; ================================================ FILE: libs/appflowy-ai-client/tests/row_test/summarize_test.rs ================================================ use crate::appflowy_ai_client; use serde_json::json; #[tokio::test] async fn summarize_row_test() { let client = appflowy_ai_client(); let json = json!({"name": "Jack", "age": 25, "city": "New York"}); let result = client .summarize_row(json.as_object().unwrap(), "gpt-4o-mini") .await .unwrap(); result.text.contains("Jack"); result.text.contains("New York"); println!("{:?}", result); } ================================================ FILE: libs/appflowy-ai-client/tests/row_test/translate_test.rs ================================================ use crate::appflowy_ai_client; use appflowy_ai_client::dto::{TranslateItem, TranslateRowData}; #[tokio::test] async fn translate_row_test() { let client = appflowy_ai_client(); let mut cells = Vec::new(); for (key, value) in [("book name", "Atomic Habits"), ("author", "James Clear")].iter() { cells.push(TranslateItem { title: key.to_string(), content: value.to_string(), }); } let data = TranslateRowData { cells, language: "Chinese".to_string(), include_header: false, }; let result = client.translate_row(data, "gpt-4o-mini").await.unwrap(); assert_eq!(result.items.len(), 2); } ================================================ FILE: libs/appflowy-proto/Cargo.toml ================================================ [package] name = "appflowy-proto" version = "0.1.0" edition = "2021" [dependencies] uuid = { workspace = true, features = ["serde"] } bytes.workspace = true thiserror.workspace = true collab-entity.workspace = true collab.workspace = true prost = { version = "0.13.4", features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_repr.workspace = true [build-dependencies] prost-build = "0.13.4" protoc-bin-vendored = { version = "3.0" } ================================================ FILE: libs/appflowy-proto/build.rs ================================================ use std::process::Command; fn main() -> Result<(), Box> { // If the `PROTOC` environment variable is set, don't use vendored `protoc` std::env::var("PROTOC").map(|_| ()).unwrap_or_else(|_| { let protoc_path = protoc_bin_vendored::protoc_bin_path().expect("protoc bin path"); let protoc_path_str = protoc_path.to_str().expect("protoc path to str"); // Set the `PROTOC` environment variable to the path of the `protoc` binary. std::env::set_var("PROTOC", protoc_path_str); }); let proto_files = vec![ "proto/messages.proto", "proto/collab.proto", "proto/notification.proto", ]; for proto_file in &proto_files { println!("cargo:rerun-if-changed={}", proto_file); } prost_build::Config::new() .out_dir("src/pb/") .compile_protos(&proto_files, &["proto/"])?; // Run rustfmt on the generated files. let files = std::fs::read_dir("src/")? .filter_map(Result::ok) .filter(|entry| { entry .path() .extension() .map(|ext| ext == "rs") .unwrap_or(false) }) .map(|entry| entry.path().display().to_string()); for file in files { Command::new("rustfmt").arg(file).status()?; } Ok(()) } ================================================ FILE: libs/appflowy-proto/proto/collab.proto ================================================ syntax = "proto3"; package collab; /** * Rid represents Redis stream message Id, which is a unique identifier * in scope of individual Redis stream - here workspace scope - assigned * to each update stored in Redis. * * Default: "0-0" */ message Rid { // UNIX epoch timestamp in milliseconds. fixed64 timestamp = 1; // In case when timestamps duplicate, this monotonically increasing // sequence number is incremented and assigned. uint32 counter = 2; } /** * SyncRequest message is send by either a server or a client, which informs about the * last collab state known to either party. * * If other side has more recent data, it should send `Update` message in the response. * If other side has missing data, it should send its own `SyncRequest` in the response. */ message SyncRequest { // Last Redis stream ID of the update received from the server, concerning corresponding // collab, this message refers to.It // // The `Rid.timestamp` field can be used to notify clients when was the last time, // collab has been synchronised. Rid last_message_id = 1; // Yjs Doc state vector encoded using lib0 v1 encoding. bytes state_vector = 2; } /** * Update message is send either in response to `SyncRequest` or independently by * the client/server. It contains the Yjs doc update that can represent incremental * changes made over corresponding collab, or full document state. */ message Update { // Redis stream message ID assigned to this update, after it has been stored by // server in Redis. // // For updates send by client, this field is not set. Rid message_id = 1; // Flags used to inform about encoding details: // - 0x00 - `payload` encoded using lib0 v1 encoding. // - 0x01 - `payload` encoded using lib0 v2 encoding. // // NOTE: in the future we could also include `payload` compression. uint32 flags = 2; // Binary update representing incremental changes over the collab, // or entire collab state delta. bytes payload = 3; } /** * AwarenessUpdate message is send to inform about the latest changes in the * Yjs doc awareness state. */ message AwarenessUpdate { // Yjs awareness update encoded using lib0 v1 encoding. bytes payload = 1; } /** * AccessChanged message is sent only by the server when we recognise, that * connected client has lost the access to a corresponding collab. */ message AccessChanged { // Flag indicating if user has read access to corresponding collab. bool can_read = 1; // Flag indicating if user has write access to corresponding collab. bool can_write = 2; // (Optional) human readable comment about the reason for access change. int32 reason = 3; } message CollabMessage { // Unique collab identifier (UUID), which this message is related to. // We're using string here, since it's easier to represent in web browser client. string object_id = 1; // Collab type - required by some of the collab read operations on the server. // NOTE: hopefully we'll be able to get rid of it in the future. int32 collab_type = 2; oneof data { SyncRequest sync_request = 3; Update update = 4; AwarenessUpdate awareness_update = 5; AccessChanged access_changed = 6; } } ================================================ FILE: libs/appflowy-proto/proto/messages.proto ================================================ syntax = "proto3"; import "collab.proto"; import "notification.proto"; package messages; /** * All messages send between client/server are wrapped into a `Message`. */ message Message { oneof payload { collab.CollabMessage collab_message = 1; notification.WorkspaceNotification notification = 2; } } ================================================ FILE: libs/appflowy-proto/proto/notification.proto ================================================ syntax = "proto3"; package notification; message WorkspaceNotification { oneof payload { UserProfileChange profile_change = 1; PermissionChanged permission_changed = 2; } } message UserProfileChange { int64 uid = 1; optional string name = 2; optional string email = 3; } message PermissionChanged { string object_id = 1; uint32 reason = 2; } ================================================ FILE: libs/appflowy-proto/src/client_message.rs ================================================ use crate::pb; use crate::pb::collab_message::Data; use crate::pb::message::Payload; #[rustfmt::skip] use crate::pb::{message, SyncRequest}; use crate::shared::{Error, ObjectId, Rid, UpdateFlags}; use collab::preclude::sync::AwarenessUpdate; use collab::preclude::updates::decoder::Decode; use collab::preclude::{StateVector, Update}; use collab_entity::CollabType; use prost::Message; use std::fmt::{Debug, Formatter}; use uuid::Uuid; /// Represents messages sent from the client to the server through the WebSocket connection. /// ClientMessage is used to synchronize collaborative data between clients. #[derive(Clone)] pub enum ClientMessage { /// Requests synchronization by providing the client's state vector. /// The server responds with updates the client is missing. /// /// # Fields /// * `object_id` - The unique identifier of the collaborative object /// * `collab_type` - The type of collaborative object (document, folder, etc.) /// * `last_message_id` - The ID of the last message received by this client /// * `state_vector` - A compressed representation of the client's document state Manifest { object_id: ObjectId, collab_type: CollabType, last_message_id: Rid, state_vector: Vec, }, /// Sends local changes to be synchronized with other clients. /// /// # Fields /// * `object_id` - The unique identifier of the collaborative object /// * `collab_type` - The type of collaborative object /// * `flags` - Encoding version flag (Lib0v1 or Lib0v2) /// * `update` - The encoded changes to be applied Update { object_id: ObjectId, collab_type: CollabType, flags: UpdateFlags, update: Vec, }, /// Shares user presence and status information with other clients. /// /// # Fields /// * `object_id` - The unique identifier of the collaborative object /// * `collab_type` - The type of collaborative object /// * `awareness` - Encoded user presence data AwarenessUpdate { object_id: ObjectId, collab_type: CollabType, awareness: Vec, }, } impl Debug for ClientMessage { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ClientMessage::Manifest { object_id, collab_type, last_message_id, state_vector, } => { let state_vector = StateVector::decode_v1(state_vector).map_err(|_| std::fmt::Error)?; f.debug_struct("Manifest") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("last_message_id", &last_message_id) .field("state_vector", &state_vector) .finish() }, ClientMessage::Update { object_id, collab_type, flags, update, } => { let update = match flags { UpdateFlags::Lib0v1 => Update::decode_v1(update), UpdateFlags::Lib0v2 => Update::decode_v2(update), } .map_err(|_| std::fmt::Error)?; f.debug_struct("Update") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("flags", &flags) .field("update", &update) .finish() }, ClientMessage::AwarenessUpdate { object_id, collab_type, awareness, } => { let awareness = AwarenessUpdate::decode_v1(awareness).map_err(|_| std::fmt::Error)?; f.debug_struct("AwarenessUpdate") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("awareness", &awareness) .finish() }, } } } impl ClientMessage { /// Returns a reference to the object ID contained in this message. pub fn object_id(&self) -> &ObjectId { match self { ClientMessage::Manifest { object_id, .. } => object_id, ClientMessage::Update { object_id, .. } => object_id, ClientMessage::AwarenessUpdate { object_id, .. } => object_id, } } /// Converts this ClientMessage into a serialized byte array. /// /// This is typically used before sending the message over the network. pub fn into_bytes(self) -> Result, Error> { Ok(pb::Message::from(self).encode_to_vec()) } /// Creates a ClientMessage from a serialized byte array. /// /// This is typically used after receiving a message from the network. pub fn from_bytes(bytes: &[u8]) -> Result { let proto = pb::Message::decode(bytes)?; Self::try_from(proto) } } /// Converts a ClientMessage into the protocol buffer message format. /// This is used for serialization before network transmission. impl From for pb::Message { fn from(value: ClientMessage) -> Self { match value { ClientMessage::Manifest { object_id, collab_type, last_message_id, state_vector, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::SyncRequest(SyncRequest { last_message_id: Some(pb::Rid { timestamp: last_message_id.timestamp, counter: last_message_id.seq_no as u32, }), state_vector, })), })), }, ClientMessage::Update { object_id, collab_type, flags, update, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::Update(pb::Update { message_id: None, flags: flags as u8 as u32, payload: update, })), })), }, ClientMessage::AwarenessUpdate { object_id, collab_type, awareness, } => { // pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::AwarenessUpdate(pb::AwarenessUpdate { payload: awareness, })), })), } }, } } } /// Attempts to convert a protocol buffer message into a ClientMessage. /// This is used for deserialization after receiving a message from the network. impl TryFrom for ClientMessage { type Error = Error; fn try_from(value: pb::Message) -> Result { match value.payload { None => Err(Error::MissingFields), Some(payload) => match payload { Payload::CollabMessage(value) => { let object_id = Uuid::parse_str(&value.object_id)?; let collab_type = CollabType::from(value.collab_type); match value.data { Some(Data::SyncRequest(proto)) => Ok(ClientMessage::Manifest { object_id, collab_type, last_message_id: Rid { timestamp: proto .last_message_id .as_ref() .ok_or(Error::MissingFields)? .timestamp, seq_no: proto .last_message_id .as_ref() .ok_or(Error::MissingFields)? .counter as u16, }, state_vector: proto.state_vector, }), Some(Data::Update(proto)) => Ok(ClientMessage::Update { object_id, collab_type, flags: UpdateFlags::try_from(proto.flags as u8)?, update: proto.payload, }), Some(Data::AwarenessUpdate(proto)) => Ok(ClientMessage::AwarenessUpdate { object_id, collab_type, awareness: proto.payload, }), _ => Err(Error::MissingFields), } }, Payload::Notification(_) => Err(Error::UnsupportedClientMessage), }, } } } ================================================ FILE: libs/appflowy-proto/src/lib.rs ================================================ mod client_message; mod pb; mod server_message; mod shared; pub use client_message::*; pub use server_message::*; pub use shared::*; ================================================ FILE: libs/appflowy-proto/src/pb/collab.rs ================================================ // This file is @generated by prost-build. /// * /// Rid represents Redis stream message Id, which is a unique identifier /// in scope of individual Redis stream - here workspace scope - assigned /// to each update stored in Redis. /// /// Default: "0-0" #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct Rid { /// UNIX epoch timestamp in milliseconds. #[prost(fixed64, tag = "1")] pub timestamp: u64, /// In case when timestamps duplicate, this monotonically increasing /// sequence number is incremented and assigned. #[prost(uint32, tag = "2")] pub counter: u32, } /// * /// SyncRequest message is send by either a server or a client, which informs about the /// last collab state known to either party. /// /// If other side has more recent data, it should send `Update` message in the response. /// If other side has missing data, it should send its own `SyncRequest` in the response. #[derive(Clone, PartialEq, ::prost::Message)] pub struct SyncRequest { /// Last Redis stream ID of the update received from the server, concerning corresponding /// collab, this message refers to.It /// /// The `Rid.timestamp` field can be used to notify clients when was the last time, /// collab has been synchronised. #[prost(message, optional, tag = "1")] pub last_message_id: ::core::option::Option, /// Yjs Doc state vector encoded using lib0 v1 encoding. #[prost(bytes = "vec", tag = "2")] pub state_vector: ::prost::alloc::vec::Vec, } /// * /// Update message is send either in response to `SyncRequest` or independently by /// the client/server. It contains the Yjs doc update that can represent incremental /// changes made over corresponding collab, or full document state. #[derive(Clone, PartialEq, ::prost::Message)] pub struct Update { /// Redis stream message ID assigned to this update, after it has been stored by /// server in Redis. /// /// For updates send by client, this field is not set. #[prost(message, optional, tag = "1")] pub message_id: ::core::option::Option, /// Flags used to inform about encoding details: /// - 0x00 - `payload` encoded using lib0 v1 encoding. /// - 0x01 - `payload` encoded using lib0 v2 encoding. /// /// NOTE: in the future we could also include `payload` compression. #[prost(uint32, tag = "2")] pub flags: u32, /// Binary update representing incremental changes over the collab, /// or entire collab state delta. #[prost(bytes = "vec", tag = "3")] pub payload: ::prost::alloc::vec::Vec, } /// * /// AwarenessUpdate message is send to inform about the latest changes in the /// Yjs doc awareness state. #[derive(Clone, PartialEq, ::prost::Message)] pub struct AwarenessUpdate { /// Yjs awareness update encoded using lib0 v1 encoding. #[prost(bytes = "vec", tag = "1")] pub payload: ::prost::alloc::vec::Vec, } /// * /// AccessChanged message is sent only by the server when we recognise, that /// connected client has lost the access to a corresponding collab. #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct AccessChanged { /// Flag indicating if user has read access to corresponding collab. #[prost(bool, tag = "1")] pub can_read: bool, /// Flag indicating if user has write access to corresponding collab. #[prost(bool, tag = "2")] pub can_write: bool, /// (Optional) human readable comment about the reason for access change. #[prost(int32, tag = "3")] pub reason: i32, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct CollabMessage { /// Unique collab identifier (UUID), which this message is related to. /// We're using string here, since it's easier to represent in web browser client. #[prost(string, tag = "1")] pub object_id: ::prost::alloc::string::String, /// Collab type - required by some of the collab read operations on the server. /// NOTE: hopefully we'll be able to get rid of it in the future. #[prost(int32, tag = "2")] pub collab_type: i32, #[prost(oneof = "collab_message::Data", tags = "3, 4, 5, 6")] pub data: ::core::option::Option, } /// Nested message and enum types in `CollabMessage`. pub mod collab_message { #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Data { #[prost(message, tag = "3")] SyncRequest(super::SyncRequest), #[prost(message, tag = "4")] Update(super::Update), #[prost(message, tag = "5")] AwarenessUpdate(super::AwarenessUpdate), #[prost(message, tag = "6")] AccessChanged(super::AccessChanged), } } ================================================ FILE: libs/appflowy-proto/src/pb/messages.rs ================================================ // This file is @generated by prost-build. /// * /// All messages send between client/server are wrapped into a `Message`. #[derive(Clone, PartialEq, ::prost::Message)] pub struct Message { #[prost(oneof = "message::Payload", tags = "1, 2")] pub payload: ::core::option::Option, } /// Nested message and enum types in `Message`. pub mod message { #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Payload { #[prost(message, tag = "1")] CollabMessage(super::super::collab::CollabMessage), #[prost(message, tag = "2")] Notification(super::super::notification::WorkspaceNotification), } } ================================================ FILE: libs/appflowy-proto/src/pb/mod.rs ================================================ mod collab; mod messages; pub mod notification; pub use collab::*; pub use messages::*; ================================================ FILE: libs/appflowy-proto/src/pb/notification.rs ================================================ // This file is @generated by prost-build. #[derive(Clone, PartialEq, ::prost::Message)] pub struct WorkspaceNotification { #[prost(oneof = "workspace_notification::Payload", tags = "1, 2")] pub payload: ::core::option::Option, } /// Nested message and enum types in `WorkspaceNotification`. pub mod workspace_notification { #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Payload { #[prost(message, tag = "1")] ProfileChange(super::UserProfileChange), #[prost(message, tag = "2")] PermissionChanged(super::PermissionChanged), } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct UserProfileChange { #[prost(int64, tag = "1")] pub uid: i64, #[prost(string, optional, tag = "2")] pub name: ::core::option::Option<::prost::alloc::string::String>, #[prost(string, optional, tag = "3")] pub email: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PermissionChanged { #[prost(string, tag = "1")] pub object_id: ::prost::alloc::string::String, #[prost(uint32, tag = "2")] pub reason: u32, } ================================================ FILE: libs/appflowy-proto/src/server_message.rs ================================================ use crate::pb; use crate::pb::collab_message::Data; use crate::pb::message::Payload; use crate::pb::notification::{PermissionChanged, UserProfileChange}; #[rustfmt::skip] use crate::pb::{SyncRequest, message}; use crate::shared::{Error, ObjectId, Rid, UpdateFlags}; use bytes::Bytes; use collab::preclude::sync::AwarenessUpdate; use collab::preclude::updates::decoder::Decode; use collab::preclude::{StateVector, Update}; use collab_entity::CollabType; use pb::notification::workspace_notification::Payload as NotificationPayload; use prost::Message; use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Display, Formatter}; use uuid::Uuid; #[derive(Clone, Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)] #[repr(u8)] pub enum AccessChangedReason { PermissionDenied = 0, ObjectDeleted = 1, } impl Display for AccessChangedReason { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { AccessChangedReason::PermissionDenied => write!(f, "PermissionDenied"), AccessChangedReason::ObjectDeleted => write!(f, "ObjectDeleted"), } } } #[derive(Clone)] pub enum ServerMessage { Manifest { object_id: ObjectId, collab_type: CollabType, last_message_id: Rid, state_vector: Vec, }, Update { object_id: ObjectId, collab_type: CollabType, flags: UpdateFlags, last_message_id: Rid, update: Bytes, }, AwarenessUpdate { object_id: ObjectId, collab_type: CollabType, awareness: Bytes, }, AccessChanges { object_id: ObjectId, collab_type: CollabType, can_read: bool, can_write: bool, reason: AccessChangedReason, }, Notification { notification: WorkspaceNotification, }, } impl ServerMessage { pub fn into_bytes(self) -> Result, Error> { Ok(pb::Message::from(self).encode_to_vec()) } pub fn from_bytes(bytes: &[u8]) -> Result { let proto = pb::Message::decode(bytes)?; Self::try_from(proto) } } impl Debug for ServerMessage { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ServerMessage::Manifest { object_id, collab_type, last_message_id, state_vector, } => { let state_vector = StateVector::decode_v1(state_vector).map_err(|_| std::fmt::Error)?; f.debug_struct("Manifest") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("last_message_id", &last_message_id) .field("state_vector", &state_vector) .finish() }, ServerMessage::Update { object_id, collab_type, flags, last_message_id, update, } => { let update = match flags { UpdateFlags::Lib0v1 => Update::decode_v1(update), UpdateFlags::Lib0v2 => Update::decode_v2(update), } .map_err(|_| std::fmt::Error)?; f.debug_struct("Update") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("flags", &flags) .field("last_message_id", &last_message_id) .field("update", &update) .finish() }, ServerMessage::AwarenessUpdate { object_id, collab_type, awareness, } => { let awareness = AwarenessUpdate::decode_v1(awareness).map_err(|_| std::fmt::Error)?; f.debug_struct("AwarenessUpdate") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("awareness", &awareness) .finish() }, ServerMessage::AccessChanges { object_id, collab_type, can_read, can_write, reason, } => f .debug_struct("PermissionDenied") .field("object_id", &object_id) .field("collab_type", &collab_type) .field("can_read", &can_read) .field("can_write", &can_write) .field("reason", &reason) .finish(), ServerMessage::Notification { notification } => f .debug_struct("WorkspaceNotification") .field("notification", ¬ification) .finish(), } } } impl From for pb::Message { fn from(value: ServerMessage) -> Self { match value { ServerMessage::Manifest { object_id, collab_type, last_message_id, state_vector, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::SyncRequest(SyncRequest { last_message_id: Some(pb::Rid { timestamp: last_message_id.timestamp, counter: last_message_id.seq_no as u32, }), state_vector, })), })), }, ServerMessage::Update { object_id, collab_type, flags, last_message_id, update, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::Update(pb::Update { flags: flags as u8 as u32, message_id: Some(pb::Rid { timestamp: last_message_id.timestamp, counter: last_message_id.seq_no as u32, }), payload: update.into(), })), })), }, ServerMessage::AwarenessUpdate { object_id, collab_type, awareness, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::AwarenessUpdate(pb::AwarenessUpdate { payload: awareness.into(), })), })), }, ServerMessage::AccessChanges { object_id, collab_type, can_read, can_write, reason, } => pb::Message { payload: Some(message::Payload::CollabMessage(pb::CollabMessage { object_id: object_id.to_string(), collab_type: collab_type as i32, data: Some(Data::AccessChanged(pb::AccessChanged { can_read, can_write, reason: reason as i32, })), })), }, ServerMessage::Notification { notification } => match notification { WorkspaceNotification::UserProfileChange { uid, email, name } => pb::Message { payload: Some(message::Payload::Notification( pb::notification::WorkspaceNotification { payload: Some(NotificationPayload::ProfileChange(UserProfileChange { uid, name, email, })), }, )), }, WorkspaceNotification::ObjectAccessChanged { object_id, reason } => pb::Message { payload: Some(message::Payload::Notification( pb::notification::WorkspaceNotification { payload: Some(NotificationPayload::PermissionChanged(PermissionChanged { object_id: object_id.to_string(), reason: reason as u32, })), }, )), }, }, } } } impl TryFrom for ServerMessage { type Error = Error; fn try_from(value: pb::Message) -> Result { match value.payload { None => Err(Error::MissingFields), Some(payload) => match payload { Payload::CollabMessage(value) => { let object_id = Uuid::parse_str(&value.object_id)?; let collab_type = CollabType::from(value.collab_type); match value.data { Some(Data::SyncRequest(proto)) => { let rid = proto.last_message_id.ok_or(Error::MissingFields)?; Ok(ServerMessage::Manifest { object_id, collab_type, last_message_id: Rid { timestamp: rid.timestamp, seq_no: rid.counter as u16, }, state_vector: proto.state_vector, }) }, Some(Data::Update(proto)) => { let rid = proto.message_id.ok_or(Error::MissingFields)?; Ok(ServerMessage::Update { object_id, collab_type, flags: UpdateFlags::try_from(proto.flags as u8) .map_err(|_| Error::MissingFields)?, last_message_id: Rid { timestamp: rid.timestamp, seq_no: rid.counter as u16, }, update: proto.payload.into(), }) }, Some(Data::AwarenessUpdate(proto)) => Ok(ServerMessage::AwarenessUpdate { object_id, collab_type, awareness: proto.payload.into(), }), Some(Data::AccessChanged(proto)) => Ok(ServerMessage::AccessChanges { object_id, collab_type, can_read: proto.can_read, can_write: proto.can_write, reason: AccessChangedReason::from(proto.reason), }), _ => Err(Error::MissingFields), } }, Payload::Notification(notification) => match notification.payload { None => Err(Error::MissingFields), Some(payload) => match payload { NotificationPayload::ProfileChange(value) => Ok(ServerMessage::Notification { notification: WorkspaceNotification::UserProfileChange { uid: value.uid, email: value.email, name: value.name, }, }), NotificationPayload::PermissionChanged(value) => { let object_id = Uuid::parse_str(&value.object_id)?; Ok(ServerMessage::Notification { notification: WorkspaceNotification::ObjectAccessChanged { object_id, reason: value.reason.into(), }, }) }, }, }, }, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WorkspaceNotification { UserProfileChange { uid: i64, email: Option, name: Option, }, ObjectAccessChanged { object_id: Uuid, reason: AccessChangedReason, }, } impl From for i32 { fn from(value: AccessChangedReason) -> Self { value as i32 } } impl From for AccessChangedReason { fn from(value: i32) -> Self { match value { 0 => AccessChangedReason::PermissionDenied, 1 => AccessChangedReason::ObjectDeleted, _ => AccessChangedReason::PermissionDenied, } } } impl From for AccessChangedReason { fn from(value: u32) -> Self { match value { 0 => AccessChangedReason::PermissionDenied, 1 => AccessChangedReason::ObjectDeleted, _ => AccessChangedReason::PermissionDenied, } } } ================================================ FILE: libs/appflowy-proto/src/shared.rs ================================================ use collab::entity::EncodedCollab; use std::fmt::{Debug, Display, Formatter}; use std::str::FromStr; use uuid::Uuid; pub type WorkspaceId = Uuid; pub type ObjectId = Uuid; /// Redis stream message ID, parsed. #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Default, Hash)] pub struct Rid { pub timestamp: u64, pub seq_no: u16, } impl Rid { pub fn new(timestamp: u64, seq_no: u16) -> Self { Rid { timestamp, seq_no } } pub fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() != 10 { return Err(Error::InvalidRid); } let timestamp = u64::from_be_bytes([ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], ]); let seq_no = u16::from_be_bytes([bytes[8], bytes[9]]); Ok(Rid { timestamp, seq_no }) } pub fn into_bytes(&self) -> [u8; 10] { let mut bytes = [0; 10]; bytes[0..8].copy_from_slice(&self.timestamp.to_be_bytes()); bytes[8..10].copy_from_slice(&self.seq_no.to_be_bytes()); bytes } } impl Display for Rid { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}-{}", self.timestamp, self.seq_no) } } impl FromStr for Rid { type Err = String; fn from_str(s: &str) -> Result { let mut parts = s.split('-'); let timestamp = parts .next() .ok_or("missing timestamp")? .parse() .map_err(|e| format!("{}", e))?; let seq_no = parts .next() .ok_or("missing sequence number")? .parse() .map_err(|e| format!("{}", e))?; Ok(Rid { timestamp, seq_no }) } } #[repr(u8)] #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] pub enum UpdateFlags { #[default] Lib0v1 = 0, Lib0v2 = 1, } impl TryFrom for UpdateFlags { type Error = Error; fn try_from(value: u8) -> Result { match value { 0 => Ok(UpdateFlags::Lib0v1), 1 => Ok(UpdateFlags::Lib0v2), tag => Err(Error::UnsupportedFlag(tag)), } } } #[derive(Debug, Clone, thiserror::Error)] pub enum Error { #[error("failed to decode message: {0}")] ProtobufDecode(#[from] prost::DecodeError), #[error("failed to encode message: {0}")] ProtobufEncode(#[from] prost::EncodeError), #[error("failed to decode object id: {0}")] InvalidObjectId(#[from] uuid::Error), #[error("failed to decode Redis stream message ID")] InvalidRid, #[error("failed to decode message: missing fields")] MissingFields, #[error("failed to decode message: unsupported flag for update: {0}")] UnsupportedFlag(u8), #[error("failed to decode message: unknown collab type: {0}")] UnknownCollabType(u8), #[error("Message does not match expected client message")] UnsupportedClientMessage, } pub struct TimestampedEncodedCollab { pub encoded_collab: EncodedCollab, pub rid: Rid, } ================================================ FILE: libs/client-api/Cargo.toml ================================================ [package] name = "client-api" version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] crate-type = ["cdylib", "rlib"] [dependencies] reqwest = { workspace = true, features = ["multipart"] } anyhow.workspace = true gotrue = { path = "../gotrue" } tracing = { version = "0.1" } thiserror = "1.0.56" bytes = "1.9.0" uuid.workspace = true futures-util = "0.3.30" futures-core = "0.3.30" parking_lot = "0.12.1" brotli = { version = "3.4.0", optional = true } async-trait.workspace = true prost = "0.13.3" url = "2.5.0" mime = "0.3.17" tokio = { workspace = true, features = ["sync", "macros"] } tokio-stream = { version = "0.1.14" } chrono = "0.4" client-websocket = { workspace = true, features = ["native-tls"] } semver = "1.0.22" zstd = { version = "0.13.2" } collab = { workspace = true } yrs = { workspace = true } collab-rt-protocol = { workspace = true } workspace-template = { workspace = true, optional = true } serde_json.workspace = true serde.workspace = true app-error = { workspace = true, features = ["tokio_error", "bincode_error"] } scraper = { version = "0.17.1", optional = true } arc-swap.workspace = true shared-entity = { workspace = true } collab-rt-entity = { workspace = true } client-api-entity.workspace = true serde_urlencoded = "0.7.1" futures.workspace = true pin-project.workspace = true percent-encoding = "2.3.1" lazy_static = { workspace = true } mime_guess = "2.0.5" appflowy-proto = { path = "../appflowy-proto" } dashmap.workspace = true tokio-tungstenite = { workspace = true, features = ["stream"] } rand = "0.8.5" smallvec = { workspace = true, features = [ "serde", "const_generics", "const_new", "write", ] } collab-plugins = { workspace = true, features = [] } [dev-dependencies] tokio = { version = "1", features = ["macros", "time", "rt"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio-retry = "0.3" tokio-util = "0.7" rayon = "1.10.0" infra = { workspace = true, features = ["file_util"] } base64 = "0.22" md5 = "0.7" [target.'cfg(not(target_arch = "wasm32"))'.dependencies.tokio] workspace = true features = ["sync", "net"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies.collab-rt-entity] workspace = true features = ["tungstenite"] [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.40" getrandom = { version = "0.2", features = ["js"] } tokio = { workspace = true, features = ["sync"] } again = { version = "0.1.2" } [features] default = ["verbose_log"] test_util = ["scraper"] template = ["workspace-template"] sync_verbose_log = ["collab-rt-protocol/verbose_log"] test_fast_sync = [] enable_brotli = ["brotli"] verbose_log = [] ================================================ FILE: libs/client-api/src/collab_sync/collab_sink.rs ================================================ use std::collections::BinaryHeap; use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use anyhow::Error; use collab::core::origin::{CollabClient, CollabOrigin}; use collab::lock::Mutex; use futures_util::SinkExt; use tokio::sync::{broadcast, watch}; use tokio::time::{interval, sleep}; use tracing::{error, trace, warn}; use crate::collab_sync::collab_stream::SeqNumCounter; use crate::collab_sync::{SinkConfig, SyncError, SyncObject}; use collab_rt_entity::{ClientCollabMessage, MsgId, ServerCollabMessage, SinkMessage}; pub(crate) const SEND_INTERVAL: Duration = Duration::from_secs(8); pub const COLLAB_SINK_DELAY_MILLIS: u64 = 500; pub struct CollabSink { uid: i64, config: SinkConfig, object: SyncObject, /// The [Sink] is used to send the messages to the remote. It might be a websocket sink or /// other sink that implements the [SinkExt] trait. sender: Arc>, /// The [SinkQueue] is used to queue the messages that are waiting to be sent to the /// remote. It will merge the messages if possible. message_queue: Arc>>, sending_messages: Arc>>, /// The [watch::Sender] is used to notify the [CollabSinkRunner] to process the pending messages. /// Sending `false` will stop the [CollabSinkRunner]. notifier: Arc>, sync_state_tx: broadcast::Sender, state: Arc, } impl Drop for CollabSink { fn drop(&mut self) { if cfg!(feature = "sync_verbose_log") { trace!("Drop CollabSink {}", self.object.object_id); } // let _ = self.notifier.send(SinkSignal::Stop); } } impl CollabSink where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, { pub fn new( uid: i64, object: SyncObject, sink: Sink, notifier: watch::Sender, sync_state_tx: broadcast::Sender, config: SinkConfig, ) -> Self { let notifier = Arc::new(notifier); let sender = Arc::new(Mutex::from(sink)); let message_queue = Arc::new(parking_lot::Mutex::new(SinkQueue::new())); let sending_messages = Arc::new(parking_lot::Mutex::new(HashSet::new())); let state = Arc::new(CollabSinkState::new()); let mut interval = interval(SEND_INTERVAL); let weak_sending_messages = Arc::downgrade(&sending_messages); let _weak_notifier = Arc::downgrade(¬ifier); let _origin = CollabOrigin::Client(CollabClient { uid, device_id: object.device_id.clone(), }); let cloned_state = state.clone(); let weak_notifier = Arc::downgrade(¬ifier); tokio::spawn(async move { // Initial delay to make sure the first tick waits for SEND_INTERVAL sleep(SEND_INTERVAL).await; loop { interval.tick().await; match weak_notifier.upgrade() { Some(notifier) => { // Removing the flying messages allows for the re-sending of the top k messages in the message queue. if let Some(sending_messages) = weak_sending_messages.upgrade() { // remove all the flying messages if the last sync is expired within the SEND_INTERVAL. if cloned_state .latest_sync .is_time_for_next_sync(SEND_INTERVAL) .await { sending_messages.lock().clear(); } } if notifier.send(SinkSignal::Proceed).is_err() { break; } }, None => break, } } }); Self { uid, object, sender, message_queue, notifier, sync_state_tx, config, sending_messages, state, } } /// Put the message into the queue and notify the sink to process the next message. /// After the [Msg] was pushed into the [SinkQueue]. The queue will pop the next msg base on /// its priority. And the message priority is determined by the [Msg] that implement the [Ord] and /// [PartialOrd] trait. Check out the [CollabMessage] for more details. /// pub fn queue_msg(&self, f: impl FnOnce(MsgId) -> ClientCollabMessage) { let _ = self.sync_state_tx.send(CollabSyncState::Syncing); let mut msg_queue = self.message_queue.lock(); let msg_id = self.state.id_counter.next(); let new_msg = f(msg_id); msg_queue.push_msg(msg_id, new_msg); drop(msg_queue); self.merge(); // Notify the sink to process the next message after 500ms. let _ = self .notifier .send(SinkSignal::ProcessAfterMillis(COLLAB_SINK_DELAY_MILLIS)); } /// When queue the init message, the sink will clear all the pending messages and send the init /// message immediately. pub fn queue_init_sync(&self, f: impl FnOnce(MsgId) -> ClientCollabMessage) { let _ = self.sync_state_tx.send(CollabSyncState::Syncing); self.clear(); // When the client is connected, remove all pending messages and send the init message. let mut msg_queue = self.message_queue.lock(); let msg_id = self.state.id_counter.next(); let init_sync = f(msg_id); msg_queue.push_msg(msg_id, init_sync); self.state.did_queue_int_sync.store(true, Ordering::SeqCst); let _ = self.notifier.send(SinkSignal::Proceed); } pub fn did_queue_init_sync(&self) -> bool { self.state.did_queue_int_sync.load(Ordering::SeqCst) } /// Returns bool value to indicate whether the init sync message should be queued. /// The init sync message should be queued if the message queue is empty or the first message /// is not the init sync message. pub fn should_queue_init_sync(&self) -> bool { let msg_queue = self.message_queue.lock(); if let Some(msg) = msg_queue.peek() { if msg.message().is_client_init_sync() { return false; } } true } pub fn clear(&self) { self.message_queue.lock().clear(); self.sending_messages.lock().clear(); } pub fn pause(&self) { if cfg!(feature = "sync_verbose_log") { trace!("{}:{} pause", self.uid, self.object.object_id); } self.state.pause_ping.store(true, Ordering::SeqCst); } pub fn resume(&self) { if cfg!(feature = "sync_verbose_log") { trace!("{}:{} resume", self.uid, self.object.object_id); } self.state.pause_ping.store(false, Ordering::SeqCst); } /// Notify the sink to process the next message and mark the current message as done. /// Returns bool value to indicate whether the message is valid. pub async fn validate_response( &self, msg_id: MsgId, server_message: &ServerCollabMessage, seq_num_counter: &Arc, ) -> Result { // safety: msg_id is not None let income_message_id = msg_id; let mut sending_messages = self.sending_messages.lock(); // if the message id is not in the sending messages, it means the message is invalid. if !sending_messages.contains(&income_message_id) { if cfg!(feature = "sync_verbose_log") { trace!( "{}: sending messages:{:?} not contains {}", self.object.object_id, sending_messages, income_message_id ); } return Ok(false); } let mut message_queue = self.message_queue.lock(); let mut is_valid = false; // if sending_messages.contains(&income_message_id) { if let Some(current_item) = message_queue.pop() { if current_item.msg_id() != income_message_id { error!( "{} expect message id:{}, but receive:{}", self.object.object_id, current_item.msg_id(), income_message_id, ); message_queue.push(current_item); } else { is_valid = true; sending_messages.remove(&income_message_id); } } if is_valid { if let ServerCollabMessage::ClientAck(ack) = server_message { if let Some(seq_num) = ack.get_seq_num() { seq_num_counter.store_ack_seq_num(seq_num); seq_num_counter.check_ack_broadcast_contiguous(&self.object.object_id)?; } } } // Check if all non-ping messages have been sent let all_non_ping_messages_sent = !message_queue .iter() .any(|item| !item.message().is_ping_sync()); // If there are no non-ping messages left in the queue, it indicates all messages have been sent if all_non_ping_messages_sent { if let Err(err) = self.sync_state_tx.send(CollabSyncState::Finished) { error!( "Failed to send SinkState::Finished for object_id '{}': {}", self.object.object_id, err ); } } else if cfg!(feature = "sync_verbose_log") { trace!( "{}: pending count:{} ids:{}", self.object.object_id, message_queue.len(), message_queue .iter() .map(|item| item.msg_id().to_string()) .collect::>() .join(",") ); } Ok(is_valid) } async fn process_next_msg(&self) { let is_empty_queue = self .message_queue .try_lock() .map(|q| q.is_empty()) .unwrap_or(true); if is_empty_queue { return; } let items = { let (mut msg_queue, mut sending_messages) = match ( self.message_queue.try_lock(), self.sending_messages.try_lock(), ) { (Some(msg_queue), Some(sending_messages)) => (msg_queue, sending_messages), _ => { warn!( "{}: failed to acquire the lock of the sink, retry later", self.object.object_id ); retry_later(Arc::downgrade(&self.notifier)); return; }, }; get_next_batch_item(&self.state, &mut sending_messages, &mut msg_queue) }; self.send_immediately(items).await; } async fn send_immediately(&self, items: Vec>) { if items.is_empty() { return; } let message_ids = items.iter().map(|item| item.msg_id()).collect::>(); let messages = items .into_iter() .map(|item| item.into_message()) .collect::>(); match self.sender.try_lock() { Ok(mut sender) => { self.state.latest_sync.update_timestamp().await; match sender.send(messages).await { Ok(_) => { if cfg!(feature = "sync_verbose_log") { trace!( "🔥client sending {} messages {:?}", self.object.object_id, message_ids ); } }, Err(err) => { error!("Failed to send error: {:?}", err.into()); self .sending_messages .lock() .retain(|id| !message_ids.contains(id)); }, } }, Err(_) => { warn!("failed to acquire the lock of the sink, retry later"); self .sending_messages .lock() .retain(|id| !message_ids.contains(id)); retry_later(Arc::downgrade(&self.notifier)); }, } } fn merge(&self) { if let (Some(sending_messages), Some(mut msg_queue)) = ( self.sending_messages.try_lock(), self.message_queue.try_lock(), ) { let mut items: Vec> = Vec::with_capacity(msg_queue.len()); let mut merged_ids = HashMap::new(); while let Some(next) = msg_queue.pop() { // If the message is in the flying messages, it means the message is sending to the remote. // So don't merge the message. if sending_messages.contains(&next.msg_id()) { items.push(next); continue; } // Try to merge the next message with the last message. Only merge when: // 1. The last message is not in the flying messages. // 2. The last message can be merged and the next message can be merged. // 3. The last message's payload size is less than the maximum payload size. if let Some(last) = items.last_mut() { let can_merge = !sending_messages.contains(&last.msg_id()) && last.message().payload_size() < self.config.maximum_payload_size && last.mergeable() && next.mergeable() && last.merge(&next, &self.config.maximum_payload_size).is_ok(); if can_merge { merged_ids .entry(last.msg_id()) .or_insert(vec![]) .push(next.msg_id()); // If the last message is merged with the next message, don't push the next message continue; } } items.push(next); } if cfg!(feature = "sync_verbose_log") { for (msg_id, merged_ids) in merged_ids { trace!( "{}: merged {:?} messages into: {:?}", self.object.object_id, merged_ids, msg_id ); } } msg_queue.extend(items); } } /// Notify the sink to process the next message. pub(crate) fn notify_next(&self) { let _ = self.notifier.send(SinkSignal::Proceed); } } fn get_next_batch_item( state: &Arc, sending_messages: &mut HashSet, msg_queue: &mut SinkQueue, ) -> Vec> { let mut next_sending_items = vec![]; let mut requeue_items = vec![]; while let Some(item) = msg_queue.pop() { // If we've already selected 20 items for sending, or if the current message // is already being sent (exists in sending_messages), we requeue the current item // and break out of the loop to prevent sending too many messages at once or // sending the same message twice. if next_sending_items.len() >= 20 || sending_messages.contains(&item.msg_id()) { requeue_items.push(item); break; } // Check if the current item is an initial synchronization message. let is_init_sync = item.message().is_client_init_sync(); // Determine if the message should be sent. Messages are sent if the initial sync // has already been queued (did_queue_int_sync is true) or if it's an initial sync message. // This ensures that initial sync messages are prioritized and that other messages // are only sent after the initial sync has been queued. if state.did_queue_int_sync.load(Ordering::SeqCst) || is_init_sync { next_sending_items.push(item.clone()); requeue_items.push(item); // If the current item is an initial sync message, we've prioritized it for sending, // so break the loop to handle its sending immediately. if is_init_sync { break; } } else { // If the current message does not meet the conditions for immediate sending // (e.g., initial sync hasn't been queued yet and this isn't an initial sync message), // we requeue it to attempt sending later. requeue_items.push(item); } } msg_queue.extend(requeue_items); let message_ids = next_sending_items .iter() .map(|item| item.msg_id()) .collect::>(); sending_messages.extend(message_ids); next_sending_items } fn retry_later(weak_notifier: Weak>) { if let Some(notifier) = weak_notifier.upgrade() { let _ = notifier.send(SinkSignal::ProcessAfterMillis(200)); } } pub struct CollabSinkRunner; impl CollabSinkRunner { /// The runner will stop if the [CollabSink] was dropped or the notifier was closed. pub async fn run( weak_sink: Weak>, mut notifier: watch::Receiver, ) where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, { loop { // stops the runner if the notifier was closed. if notifier.changed().await.is_err() { break; } if let Some(sync_sink) = weak_sink.upgrade() { let value = notifier.borrow().clone(); match value { SinkSignal::Stop => break, SinkSignal::Proceed => { sync_sink.process_next_msg().await; }, SinkSignal::ProcessAfterMillis(millis) => { sleep(Duration::from_millis(millis)).await; sync_sink.process_next_msg().await; }, } } else { break; } } } } pub trait MsgIdCounter: Send + Sync + 'static { /// Get the next message id. The message id should be unique. fn next(&self) -> MsgId; } #[derive(Debug, Default)] pub struct DefaultMsgIdCounter(Arc); impl DefaultMsgIdCounter { pub fn new() -> Self { Self::default() } pub(crate) fn next(&self) -> MsgId { self.0.fetch_add(1, Ordering::SeqCst) } } pub(crate) struct SyncTimestamp { last_sync: Mutex, } impl SyncTimestamp { fn new() -> Self { let now = Instant::now(); SyncTimestamp { last_sync: Mutex::from(now.checked_sub(Duration::from_secs(60)).unwrap_or(now)), } } /// Indicate the duration is passed since the last sync. The last sync timestamp will be updated /// after sending a new message pub async fn is_time_for_next_sync(&self, duration: Duration) -> bool { Instant::now().duration_since(*self.last_sync.lock().await) > duration } async fn update_timestamp(&self) { let mut last_sync_locked = self.last_sync.lock().await; *last_sync_locked = Instant::now(); } } pub(crate) struct CollabSinkState { pub(crate) latest_sync: SyncTimestamp, pub(crate) pause_ping: AtomicBool, pub(crate) id_counter: DefaultMsgIdCounter, pub(crate) did_queue_int_sync: AtomicBool, } impl CollabSinkState { fn new() -> Self { let msg_id_counter = DefaultMsgIdCounter::new(); CollabSinkState { latest_sync: SyncTimestamp::new(), pause_ping: AtomicBool::new(false), id_counter: msg_id_counter, did_queue_int_sync: Default::default(), } } } #[derive(Clone, Debug)] pub enum CollabSyncState { /// The sink is syncing the messages to the remote. Syncing, /// All the messages are synced to the remote. Finished, } impl CollabSyncState { pub fn is_syncing(&self) -> bool { matches!(self, CollabSyncState::Syncing) } } #[derive(Clone)] pub enum SinkSignal { Stop, Proceed, ProcessAfterMillis(u64), } pub(crate) struct SinkQueue { queue: BinaryHeap>, } impl SinkQueue where Msg: SinkMessage, { pub(crate) fn new() -> Self { Self { queue: Default::default(), } } pub(crate) fn push_msg(&mut self, msg_id: MsgId, msg: Msg) { if cfg!(feature = "sync_verbose_log") { trace!("📩 queue: {}", msg); } self.queue.push(QueueItem::new(msg, msg_id)); } } impl Deref for SinkQueue where Msg: SinkMessage, { type Target = BinaryHeap>; fn deref(&self) -> &Self::Target { &self.queue } } impl DerefMut for SinkQueue where Msg: SinkMessage, { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.queue } } #[derive(Debug, Clone)] pub(crate) struct QueueItem { inner: Msg, msg_id: MsgId, } impl QueueItem where Msg: SinkMessage, { pub fn new(msg: Msg, msg_id: MsgId) -> Self { Self { inner: msg, msg_id } } pub fn message(&self) -> &Msg { &self.inner } pub fn into_message(self) -> Msg { self.inner } pub fn msg_id(&self) -> MsgId { self.msg_id } } impl QueueItem where Msg: SinkMessage, { pub fn mergeable(&self) -> bool { self.inner.mergeable() } pub fn merge(&mut self, other: &Self, max_size: &usize) -> Result { self.inner.merge(other.message(), max_size) } } impl Eq for QueueItem where Msg: Eq {} impl PartialEq for QueueItem where Msg: PartialEq, { fn eq(&self, other: &Self) -> bool { self.inner == other.inner } } impl PartialOrd for QueueItem where Msg: PartialOrd + Ord, { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for QueueItem where Msg: Ord, { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.inner.cmp(&other.inner) } } ================================================ FILE: libs/client-api/src/collab_sync/collab_stream.rs ================================================ use std::borrow::BorrowMut; use std::marker::PhantomData; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Weak}; use std::time::Duration; use arc_swap::ArcSwap; use collab::core::origin::CollabOrigin; use collab::lock::RwLock; use collab::preclude::Collab; use futures_util::{SinkExt, StreamExt}; use tokio::select; use tokio_util::sync::CancellationToken; use tracing::{error, instrument, trace, warn}; use uuid::Uuid; use yrs::encoding::read::Cursor; use yrs::updates::decoder::DecoderV1; use yrs::updates::encoder::Encode; use yrs::ReadTxn; use client_api_entity::{validate_data_for_folder, CollabType}; use collab_rt_entity::{AckCode, ClientCollabMessage, ServerCollabMessage, ServerInit, UpdateSync}; use collab_rt_protocol::{ ClientSyncProtocol, CollabSyncProtocol, Message, MessageReader, SyncMessage, }; use crate::collab_sync::{ start_sync, CollabSink, MissUpdateReason, SyncError, SyncObject, SyncReason, }; pub type CollabRef = Weak + Send + Sync + 'static>>; /// Use to continuously receive updates from remote. pub struct ObserveCollab { #[allow(dead_code)] object_id: Uuid, #[allow(dead_code)] weak_collab: CollabRef, phantom_sink: PhantomData, phantom_stream: PhantomData, // Use sequence number to check if the received updates/broadcasts are continuous. #[allow(dead_code)] seq_num_counter: Arc, } impl Drop for ObserveCollab { fn drop(&mut self) { #[cfg(feature = "sync_verbose_log")] trace!("Drop SyncStream {}", self.object_id); } } impl ObserveCollab where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, Stream: StreamExt> + Send + Sync + Unpin + 'static, { pub fn new( origin: CollabOrigin, object: SyncObject, stream: Stream, weak_collab: CollabRef, sink: Weak>, periodic_sync_interval: Option, ) -> Self { let object_id = object.object_id; let cloned_weak_collab = weak_collab.clone() as CollabRef; let seq_num_counter = Arc::new(SeqNumCounter::default()); let cloned_seq_num_counter = seq_num_counter.clone(); let init_sync_cancel_token = ArcSwap::new(Arc::new(CancellationToken::new())); let arc_object = Arc::new(object); if let Some(interval) = periodic_sync_interval { tracing::trace!("setting periodic sync step 1 for {}", object_id); tokio::spawn(ObserveCollab::::periodic_sync_step_1( origin.clone(), sink.clone(), cloned_weak_collab.clone(), interval, object_id.to_string(), )); } tokio::spawn(ObserveCollab::::observer_collab_message( origin, arc_object, stream, cloned_weak_collab, sink, cloned_seq_num_counter, init_sync_cancel_token, )); Self { object_id, weak_collab, phantom_sink: Default::default(), phantom_stream: Default::default(), seq_num_counter, } } /// Periodically run sync step 1 to make sure that there are no missing updates from other clients. async fn periodic_sync_step_1( origin: CollabOrigin, weak_sink: Weak>, weak_collab: CollabRef, interval: Duration, object_id: String, ) { loop { tokio::time::sleep(interval).await; let sink = match weak_sink.upgrade() { Some(sink) => sink, None => break, }; let collab = match weak_collab.upgrade() { Some(collab) => collab, None => break, }; let sv = { let lock = collab.read().await; let sv = (*lock).borrow().transact().state_vector(); sv }; let msg = Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1(); trace!("Periodic sync step 1 for {}", object_id); sink.queue_msg(|msg_id| { ClientCollabMessage::new_update_sync(UpdateSync::new( origin.clone(), object_id.clone(), msg, msg_id, )) }); } } // Spawn the stream that continuously reads the doc's updates from remote. async fn observer_collab_message( origin: CollabOrigin, object: Arc, mut stream: Stream, weak_collab: CollabRef, weak_sink: Weak>, seq_num_counter: Arc, cancel_token: ArcSwap, ) { while let Some(collab_message_result) = stream.next().await { let collab = match weak_collab.upgrade() { Some(collab) => collab, None => break, // Collab dropped, stop the stream. }; let sink = match weak_sink.upgrade() { Some(sink) => sink, None => break, // Sink dropped, stop the stream. }; let msg = match collab_message_result { Ok(msg) => msg, Err(err) => { warn!( "{} stream error:{}, stop receive incoming changes", object.object_id, err.into() ); break; }, }; if let Err(error) = ObserveCollab::::process_remote_message( &object, &collab, &sink, msg, &seq_num_counter, ) .await { match error { SyncError::MissUpdates { state_vector_v1, reason, } => { let new_cancel_token = Arc::new(CancellationToken::new()); let old_cancel_token = cancel_token.swap(new_cancel_token.clone()); old_cancel_token.cancel(); let cloned_origin = origin.clone(); let cloned_object = object.clone(); let collab = collab.clone(); let sink = sink.clone(); let sync_reason = match state_vector_v1 { None => SyncReason::ClientMissUpdates { reason }, Some(sv) => SyncReason::ServerMissUpdates { state_vector_v1: sv, reason, }, }; tokio::spawn(async move { select! { _ = new_cancel_token.cancelled() => { trace!("{} cancel pull missing updates", cloned_object.object_id); }, _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => { Self::pull_missing_updates(&cloned_origin, &cloned_object, &collab, &sink, sync_reason) .await; } } }); }, SyncError::CannotApplyUpdate => { let lock = collab.read().await; if let Err(err) = start_sync( origin.clone(), &object, (*lock).borrow(), &sink, SyncReason::ServerCannotApplyUpdate, ) { error!("Error while start sync: {}", err); } }, SyncError::OverrideWithIncorrectData(_) => { error!("Error while processing message: {}", error); break; }, _ => { error!("Error while processing message: {}", error); }, } } } } /// Continuously handle messages from the remote doc async fn process_remote_message( object: &SyncObject, collab: &Arc + Send + Sync + 'static>>, sink: &Arc>, msg: ServerCollabMessage, seq_num_counter: &Arc, ) -> Result<(), SyncError> { if cfg!(feature = "sync_verbose_log") { trace!("handle server: {:?}", msg); } if let ServerCollabMessage::ClientAck(ack) = &msg { let ack_code = ack.get_code(); // if the server can not apply the update, we start the init sync. if ack_code == AckCode::CannotApplyUpdate { return Err(SyncError::CannotApplyUpdate); } if ack_code == AckCode::MissUpdate { // if the ack code is MissUpdate, it means the server has missed some updates. Client need to // use the payload of the current message to calculate missing update. So any existing pending // updates are no long needed. sink.clear(); return Err(SyncError::MissUpdates { state_vector_v1: Some(ack.payload.to_vec()), reason: MissUpdateReason::ServerMissUpdates, }); } } // msg_id will be None for [ServerBroadcast] or [ServerAwareness]. match msg.msg_id() { None => { // apply the broadcast data and then check the continuity of the broadcast sequence number. Self::process_message_follow_protocol(object, &msg, collab, sink).await?; sink.notify_next(); if let ServerCollabMessage::ServerBroadcast(ref data) = msg { seq_num_counter.check_broadcast_contiguous(&object.object_id, data.seq_num)?; seq_num_counter.store_broadcast_seq_num(data.seq_num); } Ok(()) }, Some(msg_id) => { let is_valid = sink .validate_response(msg_id, &msg, seq_num_counter) .await?; if is_valid { Self::process_message_follow_protocol(object, &msg, collab, sink).await?; } sink.notify_next(); Ok(()) }, } } #[instrument(level = "trace", skip_all)] async fn pull_missing_updates( origin: &CollabOrigin, object: &SyncObject, collab: &Arc + Send + Sync + 'static>>, sink: &Arc>, reason: SyncReason, ) { let lock = collab.read().await; if let Err(err) = start_sync(origin.clone(), object, (*lock).borrow(), sink, reason) { error!("Error while start sync: {}", err); } } async fn process_message_follow_protocol( sync_object: &SyncObject, msg: &ServerCollabMessage, collab: &Arc + Send + Sync + 'static>>, sink: &Arc>, ) -> Result<(), SyncError> { if msg.payload().is_empty() { return Ok(()); } let payload = msg.payload().clone(); let message_origin = msg.origin().clone(); let sink = sink.clone(); let sync_object = sync_object.clone(); let collab = collab.clone(); // workaround for panic when applying updates. It can be removed in the future let result = tokio::spawn(async move { let mut decoder = DecoderV1::new(Cursor::new(&payload)); let reader = MessageReader::new(&mut decoder); for yrs_message in reader { let msg = yrs_message?; // When the client receives a SyncStep1 message, it indicates that the server is requesting // the client to send updates that the server is missing. This typically occurs when the client // has been editing offline, resulting in the client's version of the collaboration object // being ahead of the server's version. In response, the client prepares to send the missing updates. let is_server_sync_step_1 = matches!(msg, Message::Sync(SyncMessage::SyncStep1(_))); // If the collaboration object is of type [CollabType::Folder], data validation is required // before sending the SyncStep1 to the server. if is_server_sync_step_1 && sync_object.collab_type == CollabType::Folder { let lock = collab.read().await; validate_data_for_folder((*lock).borrow(), &sync_object.workspace_id.to_string()) .map_err(|err| SyncError::OverrideWithIncorrectData(err.to_string()))?; } if let Some(return_payload) = ClientSyncProtocol .handle_message(&message_origin, &collab, msg) .await? { let object_id = sync_object.object_id; sink.queue_msg(|msg_id| { if is_server_sync_step_1 { ClientCollabMessage::new_server_init_sync(ServerInit::new( message_origin.clone(), object_id.to_string(), return_payload, msg_id, )) } else { ClientCollabMessage::new_update_sync(UpdateSync::new( message_origin.clone(), object_id.to_string(), return_payload, msg_id, )) } }); } } Ok::<_, SyncError>(()) }) .await; result.unwrap_or_else(|err| { error!("Panic while processing message: {:?}", err); Err(SyncError::Internal(anyhow::anyhow!( "Panic while processing message" ))) }) } } #[derive(Default)] pub struct SeqNumCounter { /// The sequence number of the last update broadcast by the server. /// This counter is incremented by 1 each time the server applies an update. pub broadcast_seq_counter: AtomicU32, /// The sequence number of the last update acknowledged by a client. /// This is set to the sequence number contained in the `CollabMessage::ClientAck` received from a client. /// If this number is greater than `broadcast_seq_counter`, it indicates that some updates are missing on the client side, /// prompting an initialization sync to rectify missing updates. pub ack_seq_counter: AtomicU32, pub miss_update_counter: AtomicU32, } impl SeqNumCounter { pub fn store_ack_seq_num(&self, seq_num: u32) -> u32 { // If the broadcast sequence counter is 0, set it to the current sequence number. if self.broadcast_seq_counter.load(Ordering::SeqCst) == 0 { self.broadcast_seq_counter.store(seq_num, Ordering::SeqCst); } match self .ack_seq_counter .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { // Check if the sequence number is less than the current one. A lower sequence number can indicate // that the server has been restarted, or the collaboration group has been reinitialized. if seq_num >= current { Some(seq_num) } else { None } }) { Ok(prev) => prev, Err(prev) => { self.ack_seq_counter.store(seq_num, Ordering::SeqCst); prev }, } } pub fn store_broadcast_seq_num(&self, broadcast_seq_num: u32) -> u32 { match self .broadcast_seq_counter .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { // Check if the sequence number is less than the current one. A lower sequence number can indicate // that the server has been restarted, or the collaboration group has been reinitialized. if broadcast_seq_num >= current { Some(broadcast_seq_num) } else { None } }) { Ok(prev) => prev, Err(prev) => { self .broadcast_seq_counter .store(broadcast_seq_num, Ordering::SeqCst); prev }, } } /// Checks if the given broadcast sequence number is contiguous with the current sequence. /// /// Verifies that the broadcast sequence number provided (`broadcast_seq_num`) follows directly after /// the last known sequence number stored in the system (`current`). /// /// If there is a gap between the `broadcast_seq_num` and `current`, it indicates that some /// messages may have been missed, and an error is returned. pub fn check_broadcast_contiguous( &self, _object_id: &Uuid, broadcast_seq_num: u32, ) -> Result<(), SyncError> { let current = self.broadcast_seq_counter.load(Ordering::SeqCst); if current > 0 && broadcast_seq_num > current + 1 { return Err(SyncError::MissUpdates { state_vector_v1: None, reason: MissUpdateReason::BroadcastSeqNotContinuous { current, expected: broadcast_seq_num, }, }); } Ok(()) } pub fn check_ack_broadcast_contiguous(&self, object_id: &Uuid) -> Result<(), SyncError> { let ack_seq_num = self.ack_seq_counter.load(Ordering::SeqCst); let broadcast_seq_num = self.broadcast_seq_counter.load(Ordering::SeqCst); if cfg!(feature = "sync_verbose_log") { trace!( "receive {} seq_num, ack:{}, broadcast:{}", object_id, ack_seq_num, broadcast_seq_num, ); } if ack_seq_num > broadcast_seq_num { // calculate the number of times the ack is greater than the broadcast. We don't do return MissingUpdates // immediately, because the ack may be greater than the broadcast for a short time. let old = self.miss_update_counter.fetch_add(1, Ordering::SeqCst); if old + 1 >= 2 { self.miss_update_counter.store(0, Ordering::SeqCst); // Mark the broadcast sequence number as ack seq_num because a MissUpdates error triggers // an initialization synchronization. After this initial sync, the ack and broadcast sequence // numbers are expected to align, ensuring that all updates are synchronized. self .broadcast_seq_counter .store(ack_seq_num, Ordering::SeqCst); return Err(SyncError::MissUpdates { state_vector_v1: None, reason: MissUpdateReason::AckSeqAdvanceBroadcastSeq { ack_seq: ack_seq_num, broadcast_seq: broadcast_seq_num, }, }); } } Ok(()) } } ================================================ FILE: libs/client-api/src/collab_sync/error.rs ================================================ use collab_rt_protocol::RTProtocolError; use std::fmt::Display; #[derive(Debug, thiserror::Error)] pub enum SyncError { #[error(transparent)] YSync(RTProtocolError), #[error(transparent)] YAwareness(#[from] collab::core::awareness::Error), #[error("failed to deserialize message: {0}")] DecodingError(#[from] yrs::encoding::read::Error), #[error("Can not apply update for object:{0}")] YrsApplyUpdate(String), #[error(transparent)] SerdeError(#[from] serde_json::Error), #[error(transparent)] TokioTask(#[from] tokio::task::JoinError), #[error(transparent)] IO(#[from] std::io::Error), #[error("Workspace id is not found")] NoWorkspaceId, #[error("Missing updates")] MissUpdates { state_vector_v1: Option>, reason: MissUpdateReason, }, #[error("Can not apply update")] CannotApplyUpdate, #[error("{0}")] OverrideWithIncorrectData(String), #[error(transparent)] Internal(#[from] anyhow::Error), } #[derive(Debug)] pub enum MissUpdateReason { BroadcastSeqNotContinuous { current: u32, expected: u32 }, AckSeqAdvanceBroadcastSeq { ack_seq: u32, broadcast_seq: u32 }, ServerMissUpdates, Other(String), } impl Display for MissUpdateReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MissUpdateReason::BroadcastSeqNotContinuous { current, expected } => { write!( f, "Broadcast sequence not continuous: current={}, expected={}", current, expected ) }, MissUpdateReason::AckSeqAdvanceBroadcastSeq { ack_seq, broadcast_seq, } => { write!( f, "Ack sequence advance broadcast sequence: ack_seq={}, broadcast_seq={}", ack_seq, broadcast_seq ) }, MissUpdateReason::ServerMissUpdates => write!(f, "Server miss updates"), MissUpdateReason::Other(reason) => write!(f, "{}", reason), } } } impl From for SyncError { fn from(value: RTProtocolError) -> Self { match value { RTProtocolError::MissUpdates { state_vector_v1, reason, } => Self::MissUpdates { state_vector_v1, reason: MissUpdateReason::Other(reason), }, RTProtocolError::DecodingError(e) => Self::DecodingError(e), RTProtocolError::YAwareness(e) => Self::YAwareness(e), RTProtocolError::YrsApplyUpdate(e) => Self::YrsApplyUpdate(e), RTProtocolError::Internal(e) => Self::Internal(e), _ => Self::YSync(value), } } } impl SyncError { pub fn is_cannot_apply_update(&self) -> bool { matches!(self, Self::YrsApplyUpdate(_)) } } ================================================ FILE: libs/client-api/src/collab_sync/mod.rs ================================================ mod collab_sink; mod collab_stream; mod error; mod plugin; mod sync_control; pub use collab_rt_entity::{MsgId, ServerCollabMessage}; pub use collab_sink::*; pub use error::*; pub use plugin::*; pub use sync_control::*; ================================================ FILE: libs/client-api/src/collab_sync/plugin.rs ================================================ use std::future::Future; use std::pin::Pin; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Weak}; use std::time::Duration; use anyhow::anyhow; use collab::core::awareness::{AwarenessUpdate, Event}; use collab::core::collab_plugin::CollabPluginType; use collab::core::collab_state::SyncState; use collab::core::origin::CollabOrigin; use collab::preclude::{Collab, CollabPlugin}; use futures_util::SinkExt; use tokio_retry::strategy::FixedInterval; use tokio_retry::{Action, Condition, RetryIf}; use tokio_stream::StreamExt; use tracing::{error, trace}; use uuid::Uuid; use yrs::updates::encoder::Encode; use client_api_entity::{CollabObject, CollabType}; use collab_rt_entity::{ClientCollabMessage, ServerCollabMessage, UpdateSync}; use collab_rt_protocol::{Message, SyncMessage}; use crate::collab_sync::collab_stream::CollabRef; use crate::collab_sync::{CollabSyncState, SinkConfig, SyncControl, SyncReason}; use crate::ws::{ConnectState, WSConnectStateReceiver}; pub struct SyncPlugin { object: SyncObject, sync_queue: Arc>, // Used to keep the lifetime of the channel #[allow(dead_code)] channel: Option>, collab: CollabRef, is_destroyed: Arc, } impl Drop for SyncPlugin { fn drop(&mut self) { #[cfg(feature = "sync_verbose_log")] trace!("Drop sync plugin: {}", self.object.object_id); // when the plugin is dropped, set the is_destroyed flag to true self .is_destroyed .store(true, std::sync::atomic::Ordering::SeqCst); } } impl SyncPlugin where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, Stream: StreamExt> + Send + Sync + Unpin + 'static, Channel: Send + Sync + 'static, { #[allow(clippy::too_many_arguments)] pub fn new( origin: CollabOrigin, object: SyncObject, collab: CollabRef, sink: Sink, sink_config: SinkConfig, stream: Stream, channel: Option>, mut ws_connect_state: WSConnectStateReceiver, periodic_sync: Option, ) -> Self { let sync_queue = SyncControl::new( object.clone(), origin, sink, sink_config, stream, collab.clone(), periodic_sync, ); let mut sync_state_stream = sync_queue.subscribe_sync_state(); let sync_state_collab = collab.clone(); tokio::spawn(async move { while let Ok(sink_state) = sync_state_stream.recv().await { if let Some(collab) = sync_state_collab.upgrade() { let sync_state = match sink_state { CollabSyncState::Syncing => SyncState::Syncing, _ => SyncState::SyncFinished, }; let lock = collab.read().await; lock.borrow().get_state().set_sync_state(sync_state); } else { break; } } }); let sync_queue = Arc::new(sync_queue); let weak_local_collab = collab.clone(); let weak_sync_queue = Arc::downgrade(&sync_queue); tokio::spawn(async move { while let Ok(connect_state) = ws_connect_state.recv().await { match connect_state { ConnectState::Connected => { // If the websocket is connected, initialize a new init sync if let (Some(local_collab), Some(sync_queue)) = (weak_local_collab.upgrade(), weak_sync_queue.upgrade()) { sync_queue.resume(); let lock = local_collab.read().await; let _ = sync_queue.init_sync(lock.borrow(), SyncReason::NetworkResume); } else { break; } }, ConnectState::Unauthorized | ConnectState::Lost => { if let Some(sync_queue) = weak_sync_queue.upgrade() { // Stop sync if the websocket is unauthorized or disconnected sync_queue.pause(); } else { break; } }, _ => {}, } } }); Self { sync_queue, object, channel, collab, is_destroyed: Arc::new(Default::default()), } } } impl CollabPlugin for SyncPlugin where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, Stream: StreamExt> + Send + Sync + Unpin + 'static, Channel: Send + Sync + 'static, { fn plugin_type(&self) -> CollabPluginType { CollabPluginType::CloudStorage } fn did_init(&self, _collab: &Collab, _object_id: &str) { // Most of the time, it should be successful to queue init sync by 1st time. let retry_strategy = FixedInterval::new(Duration::from_secs(1)).take(10); let action = InitSyncAction { sync_queue: Arc::downgrade(&self.sync_queue), collab: self.collab.clone(), }; let condition = InitSyncRetryCondition { is_plugin_destroyed: self.is_destroyed.clone(), }; tokio::spawn(async move { if let Err(err) = RetryIf::spawn(retry_strategy, action, condition).await { error!("Failed to start init sync: {}", err); } }); } fn receive_local_update(&self, origin: &CollabOrigin, _object_id: &str, update: &[u8]) { let update = update.to_vec(); let payload = Message::Sync(SyncMessage::Update(update)).encode_v1(); self.sync_queue.queue_msg(|msg_id| { let update_sync = UpdateSync::new( origin.clone(), self.object.object_id.to_string(), payload, msg_id, ); ClientCollabMessage::new_update_sync(update_sync) }); } fn receive_local_state( &self, origin: &CollabOrigin, object_id: &str, _event: &Event, update: &AwarenessUpdate, ) { let payload = Message::Awareness(update.encode_v1()).encode_v1(); self.sync_queue.queue_msg(|msg_id| { let update_sync = UpdateSync::new(origin.clone(), object_id.to_string(), payload, msg_id); if cfg!(feature = "sync_verbose_log") { trace!("queue awareness: {:?}", update); } ClientCollabMessage::new_awareness_sync(update_sync) }); } fn start_init_sync(&self) { let collab = self.collab.clone(); let sync_queue = self.sync_queue.clone(); tokio::spawn(async move { if let Some(collab) = collab.upgrade() { let lock = collab.read().await; if let Err(err) = sync_queue.init_sync(lock.borrow(), SyncReason::CollabInitialize) { error!("Failed to start init sync: {}", err); } } }); } fn destroy(&self) { self .is_destroyed .store(true, std::sync::atomic::Ordering::SeqCst); } } #[derive(Clone, Debug)] pub struct SyncObject { pub object_id: Uuid, pub workspace_id: Uuid, pub collab_type: CollabType, pub device_id: String, } impl SyncObject { pub fn new( object_id: Uuid, workspace_id: Uuid, collab_type: CollabType, device_id: &str, ) -> Self { Self { object_id, workspace_id, collab_type, device_id: device_id.to_string(), } } } impl TryFrom for SyncObject { type Error = anyhow::Error; fn try_from(collab_object: CollabObject) -> Result { Ok(Self { object_id: Uuid::parse_str(&collab_object.object_id)?, workspace_id: Uuid::parse_str(&collab_object.workspace_id)?, collab_type: collab_object.collab_type, device_id: collab_object.device_id, }) } } pub(crate) struct InitSyncAction { sync_queue: Weak>, collab: CollabRef, } impl Action for InitSyncAction where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, Stream: StreamExt> + Send + Sync + Unpin + 'static, { type Future = Pin> + Send + Sync>>; type Item = (); type Error = anyhow::Error; fn run(&mut self) -> Self::Future { let weak_queue = self.sync_queue.clone(); let weak_collab = self.collab.clone(); Box::pin(async move { if let (Some(queue), Some(collab)) = (weak_queue.upgrade(), weak_collab.upgrade()) { if queue.did_queue_init_sync() { return Ok(()); } let lock = collab.read().await; let collab = (*lock).borrow(); let is_queue = queue.init_sync(collab, SyncReason::CollabInitialize)?; if is_queue { return Ok(()); } else { return Err(anyhow!("Failed to queue init sync")); } } // If the queue or collab is dropped, return Ok to stop retrying. Ok(()) }) } } pub(crate) struct InitSyncRetryCondition { is_plugin_destroyed: Arc, } impl Condition for InitSyncRetryCondition { fn should_retry(&mut self, _error: &anyhow::Error) -> bool { // Only retry if the plugin is not destroyed !self .is_plugin_destroyed .load(std::sync::atomic::Ordering::SeqCst) } } ================================================ FILE: libs/client-api/src/collab_sync/sync_control.rs ================================================ use std::fmt::Display; use std::ops::Deref; use std::sync::Arc; use std::time::Duration; use collab::core::awareness::Awareness; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use futures_util::{SinkExt, StreamExt}; use tokio::sync::{broadcast, watch}; use tracing::{error, info, instrument, trace}; use yrs::updates::decoder::Decode; use yrs::updates::encoder::{Encode, Encoder, EncoderV1}; use yrs::{ReadTxn, StateVector}; use collab_rt_entity::{ClientCollabMessage, InitSync, ServerCollabMessage, UpdateSync}; use collab_rt_protocol::{ClientSyncProtocol, CollabSyncProtocol, Message, SyncMessage}; use crate::collab_sync::collab_stream::{CollabRef, ObserveCollab}; use crate::collab_sync::{ CollabSink, CollabSinkRunner, CollabSyncState, MissUpdateReason, SinkSignal, SyncError, SyncObject, }; pub const DEFAULT_SYNC_TIMEOUT: u64 = 10; pub struct SyncControl { object: SyncObject, pub(crate) origin: CollabOrigin, /// The [CollabSink] is used to send the updates to the remote. It will send the current /// update periodically if the timeout is reached or it will send the next update if /// it receive previous ack from the remote. sink: Arc>, /// The [ObserveCollab] will be spawned in a separate task It continuously receive /// the updates from the remote. #[allow(dead_code)] observe_collab: ObserveCollab, sync_state_tx: broadcast::Sender, } impl Drop for SyncControl { fn drop(&mut self) { #[cfg(feature = "sync_verbose_log")] trace!("Drop SyncQueue {}", self.object.object_id); } } impl SyncControl where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, Stream: StreamExt> + Send + Sync + Unpin + 'static, { #[allow(clippy::too_many_arguments)] pub fn new( object: SyncObject, origin: CollabOrigin, sink: Sink, sink_config: SinkConfig, stream: Stream, collab: CollabRef, periodic_sync: Option, ) -> Self { let (notifier, notifier_rx) = watch::channel(SinkSignal::Proceed); let (sync_state_tx, _) = broadcast::channel(10); debug_assert!(origin.client_user_id().is_some()); // Create the sink and start the sink runner. let sink = Arc::new(CollabSink::new( origin.client_user_id().unwrap_or(0), object.clone(), sink, notifier, sync_state_tx.clone(), sink_config, )); tokio::spawn(CollabSinkRunner::run(Arc::downgrade(&sink), notifier_rx)); // Create the observe collab stream. let stream = ObserveCollab::new( origin.clone(), object.clone(), stream, collab.clone(), Arc::downgrade(&sink), periodic_sync, ); Self { object, origin, sink, observe_collab: stream, sync_state_tx, } } pub fn pause(&self) { info!("pause {} sync", self.object.object_id); self.sink.pause(); } pub fn resume(&self) { info!("resume {} sync", self.object.object_id); self.sink.resume(); } pub fn subscribe_sync_state(&self) -> broadcast::Receiver { self.sync_state_tx.subscribe() } /// Returns bool indicating whether the init sync is queued. pub fn init_sync( &self, collab: &collab::preclude::Collab, reason: SyncReason, ) -> Result { start_sync( self.origin.clone(), &self.object, collab, &self.sink, reason, ) } } pub enum SyncReason { CollabInitialize, ServerMissUpdates { state_vector_v1: Vec, reason: MissUpdateReason, }, ClientMissUpdates { reason: MissUpdateReason, }, ServerCannotApplyUpdate, NetworkResume, } impl Display for SyncReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SyncReason::CollabInitialize => write!(f, "CollabInitialize"), SyncReason::ServerMissUpdates { reason, .. } => write!(f, "ServerMissUpdates: {}", reason), SyncReason::ClientMissUpdates { reason } => write!(f, "ClientMissUpdates: {}", reason), SyncReason::ServerCannotApplyUpdate => write!(f, "ServerCannotApplyUpdate"), SyncReason::NetworkResume => write!(f, "NetworkResume"), } } } fn gen_sync_state( awareness: &Awareness, protocol: &P, ) -> Result, SyncError> { let mut encoder = EncoderV1::new(); protocol.start(awareness, &mut encoder)?; Ok(encoder.to_vec()) } fn gen_missing_updates(collab: &Collab, sv: StateVector) -> Result, SyncError> { let update = { let txn = collab.transact(); txn.encode_state_as_update_v1(&sv) }; let mut encoder = EncoderV1::new(); Message::Sync(SyncMessage::Update(update)).encode(&mut encoder); Ok(encoder.to_vec()) } #[instrument(level = "trace", skip_all)] pub fn start_sync( origin: CollabOrigin, sync_object: &SyncObject, collab: &Collab, sink: &Arc>, reason: SyncReason, ) -> Result where E: Into + Send + Sync + 'static, Sink: SinkExt, Error = E> + Send + Sync + Unpin + 'static, { if let Err(err) = sync_object.collab_type.validate_require_data(collab) { return Err(SyncError::Internal(err.into())); } match reason { SyncReason::ClientMissUpdates { reason } => { if !sink.should_queue_init_sync() { return Ok(false); } tracing::debug!( "🔥{} restart sync due to missing update, reason:{}", &sync_object.object_id, reason ); let awareness = collab.get_awareness(); let payload = gen_sync_state(awareness, &ClientSyncProtocol)?; sink.queue_init_sync(|msg_id| { let init_sync = InitSync::new( origin, sync_object.object_id.to_string(), sync_object.collab_type, sync_object.workspace_id.to_string(), msg_id, payload, ); ClientCollabMessage::new_init_sync(init_sync) }); }, SyncReason::ServerMissUpdates { state_vector_v1, reason, } => match StateVector::decode_v1(&state_vector_v1) { Ok(sv) => { trace!("🔥{} start sync, reason:{}", &sync_object.object_id, reason); let update = gen_missing_updates(collab, sv)?; sink.queue_msg(|msg_id| { let update_sync = UpdateSync::new( origin.clone(), sync_object.object_id.to_string(), update, msg_id, ); ClientCollabMessage::new_update_sync(update_sync) }); }, Err(err) => error!("fail to decode server state vector: {}", err), }, SyncReason::CollabInitialize | SyncReason::ServerCannotApplyUpdate | SyncReason::NetworkResume => { tracing::debug!( "🔥{} resume network, reason: {}", &sync_object.object_id, reason ); let awareness = collab.get_awareness(); let payload = gen_sync_state(awareness, &ClientSyncProtocol)?; sink.queue_init_sync(|msg_id| { let init_sync = InitSync::new( origin, sync_object.object_id.to_string(), sync_object.collab_type, sync_object.workspace_id.to_string(), msg_id, payload, ); ClientCollabMessage::new_init_sync(init_sync) }); }, }; Ok(true) } impl Deref for SyncControl { type Target = Arc>; fn deref(&self) -> &Self::Target { &self.sink } } pub struct SinkConfig { /// `timeout` is the time to wait for the remote to ack the message. If the remote /// does not ack the message in time, the message will be sent again. pub send_timeout: Duration, /// `maximum_payload_size` is the maximum size of the messages to be merged. pub maximum_payload_size: usize, } impl SinkConfig { pub fn new() -> Self { Self::default() } pub fn send_timeout(mut self, secs: u64) -> Self { self.send_timeout = Duration::from_secs(secs); self } } impl Default for SinkConfig { fn default() -> Self { Self { send_timeout: Duration::from_secs(DEFAULT_SYNC_TIMEOUT), maximum_payload_size: 1024 * 10, } } } ================================================ FILE: libs/client-api/src/http.rs ================================================ use crate::notify::{ClientToken, TokenStateReceiver}; use app_error::AppError; use app_error::ErrorCode; use client_api_entity::auth_dto::DeleteUserQuery; use client_api_entity::server_info_dto::ServerInfoResponseItem; use client_api_entity::workspace_dto::FavoriteSectionItems; use client_api_entity::workspace_dto::RecentSectionItems; use client_api_entity::workspace_dto::TrashSectionItems; use client_api_entity::workspace_dto::{FolderView, QueryWorkspaceFolder, QueryWorkspaceParam}; use client_api_entity::AuthProvider; use client_api_entity::GetInvitationCodeInfoQuery; use client_api_entity::InvitationCodeInfo; use client_api_entity::InvitedWorkspace; use client_api_entity::JoinWorkspaceByInviteCodeParams; use client_api_entity::WorkspaceInviteCodeParams; use client_api_entity::WorkspaceInviteToken as WorkspaceInviteCode; use gotrue::grant::PasswordGrant; use gotrue::grant::{Grant, RefreshTokenGrant}; use gotrue::params::{AdminUserParams, GenerateLinkParams}; use gotrue::params::{MagicLinkParams, VerifyParams, VerifyType}; use reqwest::StatusCode; use shared_entity::dto::workspace_dto::{CreateWorkspaceParam, PatchWorkspaceParam}; use std::borrow::Cow; use std::fmt::{Display, Formatter}; #[cfg(feature = "enable_brotli")] use std::io::Read; use parking_lot::RwLock; use reqwest::Method; use reqwest::RequestBuilder; use crate::retry::{RefreshTokenAction, RefreshTokenRetryCondition}; use crate::ws::ConnectInfo; use anyhow::anyhow; use client_api_entity::SignUpResponse::{Authenticated, NotAuthenticated}; use client_api_entity::{AFUserProfile, AFUserWorkspaceInfo, AFWorkspace}; use client_api_entity::{GotrueTokenResponse, UpdateGotrueUserParams, User}; use semver::Version; use shared_entity::dto::auth_dto::UpdateUserParams; use shared_entity::dto::auth_dto::{SignInPasswordResponse, SignInTokenResponse}; use shared_entity::dto::workspace_dto::WorkspaceSpaceUsage; use shared_entity::response::{AppResponse, AppResponseError}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio_retry::strategy::FixedInterval; use tokio_retry::RetryIf; use tracing::{debug, error, event, info, instrument, trace, warn}; use url::Url; use uuid::Uuid; pub const X_COMPRESSION_TYPE: &str = "X-Compression-Type"; pub const X_COMPRESSION_BUFFER_SIZE: &str = "X-Compression-Buffer-Size"; pub const X_COMPRESSION_TYPE_BROTLI: &str = "brotli"; #[derive(Clone)] pub struct ClientConfiguration { /// Lower Levels (0-4): Faster compression and decompression speeds but lower compression ratios. Suitable for scenarios where speed is more critical than reducing data size. /// Medium Levels (5-9): A balance between compression ratio and speed. These levels are generally good for a mix of performance and efficiency. /// Higher Levels (10-11): The highest compression ratios, but significantly slower and more resource-intensive. These are typically used in scenarios where reducing data size is paramount and resource usage is a secondary concern, such as for static content compression in web servers. pub(crate) compression_quality: u32, /// A larger buffer size means more data is compressed in a single operation, which can lead to better compression ratios /// since Brotli has more data to analyze for patterns and repetitions. pub(crate) compression_buffer_size: usize, } impl ClientConfiguration { pub fn with_compression_buffer_size(mut self, compression_buffer_size: usize) -> Self { self.compression_buffer_size = compression_buffer_size; self } pub fn with_compression_quality(mut self, compression_quality: u32) -> Self { self.compression_quality = if compression_quality > 11 { warn!("compression_quality is larger than 11, set it to 11"); 11 } else { compression_quality }; self } } impl Default for ClientConfiguration { fn default() -> Self { Self { compression_quality: 8, compression_buffer_size: 10240, } } } /// `Client` is responsible for managing communication with the GoTrue API and cloud storage. /// /// It provides methods to perform actions like signing in, signing out, refreshing tokens, /// and interacting with file storage and collaboration objects. /// /// # Fields /// - `cloud_client`: A `reqwest::Client` used for HTTP requests to the cloud. /// - `gotrue_client`: A `gotrue::api::Client` used for interacting with the GoTrue API. /// - `base_url`: The base URL for API requests. /// - `ws_addr`: The WebSocket address for real-time communication. /// - `token`: An `Arc>` managing the client's authentication token. /// #[derive(Clone)] pub struct Client { pub cloud_client: reqwest::Client, pub(crate) gotrue_client: gotrue::api::Client, pub base_url: String, pub ws_addr: String, pub device_id: String, pub client_version: Version, pub(crate) token: Arc>, pub(crate) is_refreshing_token: Arc, pub(crate) refresh_ret_txs: Arc>>, pub(crate) config: ClientConfiguration, pub(crate) ai_model: Arc>, } pub(crate) type RefreshTokenSender = tokio::sync::oneshot::Sender>; /// Hardcoded schema in the frontend application. Do not change this value. const DESKTOP_CALLBACK_URL: &str = "appflowy-flutter://login-callback"; impl Client { /// Constructs a new `Client` instance. /// /// # Parameters /// - `base_url`: The base URL for API requests. /// - `ws_addr`: The WebSocket address for real-time communication. /// - `gotrue_url`: The URL for the GoTrue API. pub fn new( base_url: &str, ws_addr: &str, gotrue_url: &str, device_id: &str, config: ClientConfiguration, client_id: &str, ) -> Self { let reqwest_client = reqwest::Client::new(); let client_version = Version::parse(client_id).unwrap_or_else(|_| Version::new(0, 6, 7)); let min_version = Version::new(0, 6, 7); let max_version = Version::new(1, 0, 0); // Log warnings in debug mode if the version is out of the valid range if cfg!(debug_assertions) { if client_version < min_version { error!( "Client version is less than {}, setting it to {}", min_version, min_version ); } else if client_version >= max_version { error!( "Client version is greater than or equal to {}, setting it to {}", max_version, min_version ); } } let client_version = client_version.clamp(min_version, max_version); #[cfg(debug_assertions)] { let feature_flags = [ ("sync_verbose_log", cfg!(feature = "sync_verbose_log")), ("enable_brotli", cfg!(feature = "enable_brotli")), // Add more features here as needed. ]; let enabled_features: Vec<&str> = feature_flags .iter() .filter_map(|&(name, enabled)| if enabled { Some(name) } else { None }) .collect(); trace!( "Client version: {}, features: {:?}", client_version, enabled_features ); } let ai_model = Arc::new(RwLock::new("Auto".to_string())); Self { base_url: base_url.to_string(), ws_addr: ws_addr.to_string(), cloud_client: reqwest_client.clone(), gotrue_client: gotrue::api::Client::new(reqwest_client, gotrue_url), token: Arc::new(RwLock::new(ClientToken::new())), is_refreshing_token: Default::default(), refresh_ret_txs: Default::default(), config, device_id: device_id.to_string(), client_version, ai_model, } } pub fn base_url(&self) -> &str { &self.base_url } pub fn ws_addr(&self) -> &str { &self.ws_addr } pub fn gotrue_url(&self) -> &str { &self.gotrue_client.base_url } pub fn set_ai_model(&self, model: String) { info!("using ai model: {:?}", model); *self.ai_model.write() = model; } #[instrument(level = "debug", skip_all, err)] pub fn restore_token(&self, token: &str) -> Result<(), AppResponseError> { match serde_json::from_str::(token) { Ok(token) => { self.token.write().set(token); Ok(()) }, Err(err) => { error!("fail to deserialize token:{}, error:{}", token, err); Err(err.into()) }, } } /// Retrieves the string representation of the [GotrueTokenResponse]. The returned value can be /// saved to the client application's local storage and used to restore the client's authentication /// /// This function attempts to acquire a read lock on `self.token` and retrieves the /// string representation of the access token. If the lock cannot be acquired or /// the token is not present, an error is returned. #[instrument(level = "debug", skip_all, err)] pub fn get_token_str(&self) -> Result { let token_str = self .token .read() .try_get() .map_err(|err| AppResponseError::from(AppError::OAuthError(err.to_string())))?; Ok(token_str) } #[instrument(level = "debug", skip_all, err)] pub fn get_token(&self) -> Result { let guard = self.token.read(); let resp = guard .as_ref() .ok_or_else(|| AppResponseError::new(ErrorCode::UserUnAuthorized, "user is not logged in"))?; Ok(resp.clone()) } pub fn get_access_token(&self) -> Result { self .token .read() .as_ref() .map(|v| v.access_token.clone()) .ok_or_else(|| AppResponseError::new(ErrorCode::UserUnAuthorized, "user is not logged in")) } pub fn subscribe_token_state(&self) -> TokenStateReceiver { self.token.read().subscribe() } #[instrument(skip_all, err)] pub async fn sign_in_password( &self, email: &str, password: &str, ) -> Result { let response = self .gotrue_client .token(&Grant::Password(PasswordGrant { email: email.to_owned(), password: password.to_owned(), })) .await?; let is_new = self.verify_token_cloud(&response.access_token).await?; self.token.write().set(response.clone()); Ok(SignInPasswordResponse { gotrue_response: response, is_new, }) } /// Sign in with magic link /// /// User will receive an email with a magic link to sign in. /// The redirect_to parameter is optional. If provided, the user will be redirected to the specified URL after signing in. /// If not, the user will be redirected to the appflowy-flutter:// by default /// /// The redirect_to should be the scheme of the app, e.g., appflowy-flutter:// #[instrument(level = "debug", skip_all, err)] pub async fn sign_in_with_magic_link( &self, email: &str, redirect_to: Option, ) -> Result<(), AppResponseError> { self .gotrue_client .magic_link( &MagicLinkParams { email: email.to_owned(), ..Default::default() }, redirect_to, ) .await?; Ok(()) } /// Sign in with recovery token /// /// User will receive an email with a recovery code to sign in after clicking Forget Password. #[instrument(level = "debug", skip_all, err)] pub async fn sign_in_with_recovery_code( &self, email: &str, passcode: &str, ) -> Result { let response = self .gotrue_client .verify(&VerifyParams { email: email.to_owned(), token: passcode.to_owned(), type_: VerifyType::Recovery, }) .await?; let _ = self.verify_token_cloud(&response.access_token).await?; self.token.write().set(response.clone()); Ok(response) } /// Sign in with passcode (OTP) /// /// User will receive an email with a passcode to sign in. /// /// For more information, please refer to the sign_in_with_magic_link function. #[instrument(level = "debug", skip_all, err)] pub async fn sign_in_with_passcode( &self, email: &str, passcode: &str, ) -> Result { let response = self .gotrue_client .verify(&VerifyParams { email: email.to_owned(), token: passcode.to_owned(), type_: VerifyType::MagicLink, }) .await?; let _ = self.verify_token_cloud(&response.access_token).await?; self.token.write().set(response.clone()); Ok(response) } /// Attempts to sign in using a URL, extracting refresh_token from the URL. /// It looks like, e.g., `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`. /// /// return a bool indicating if the user is new #[instrument(level = "debug", skip_all, err)] pub async fn sign_in_with_url(&self, url: &str) -> Result { let parsed = Url::parse(url)?; let key_value_pairs = parsed .fragment() .ok_or(url_missing_param("fragment"))? .split('&'); let mut refresh_token: Option<&str> = None; let mut provider_token: Option = None; let mut provider_refresh_token: Option = None; for param in key_value_pairs { match param.split_once('=') { Some(pair) => { let (k, v) = pair; if k == "refresh_token" { refresh_token = Some(v); } else if k == "provider_token" { provider_token = Some(v.to_string()); } else if k == "provider_refresh_token" { provider_refresh_token = Some(v.to_string()); } }, None => warn!("param is not in key=value format: {}", param), } } let refresh_token = refresh_token.ok_or(url_missing_param("refresh_token"))?; let mut new_token = self .gotrue_client .token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token: refresh_token.to_owned(), })) .await?; // refresh endpoint does not return provider token // so we need to set it manually to preserve this information new_token.provider_access_token = provider_token; new_token.provider_refresh_token = provider_refresh_token; let (_user, new) = self.verify_token(&new_token.access_token).await?; self.token.write().set(new_token); Ok(new) } /// Returns an OAuth URL by constructing the authorization URL for the specified provider. #[instrument(level = "debug", skip_all, err)] pub async fn generate_oauth_url_with_provider( &self, provider: &AuthProvider, ) -> Result { self .generate_url_with_provider_and_redirect_to(provider, None) .await } /// Returns an OAuth URL by constructing the authorization URL for the specified provider and redirecting to the specified URL. /// /// This asynchronous function communicates with the GoTrue client to retrieve settings and /// validate the availability of the specified OAuth provider. If the provider is available, /// it constructs and returns the OAuth URL. When the user opens the OAuth URL, it redirects to /// the corresponding provider's OAuth web page. After the user is authenticated, the browser will open /// a deep link to the AppFlowy app (iOS, macOS, etc.), which will call [Client::sign_in_with_url] to sign in. /// /// For example, the OAuth URL on Google looks like `https://appflowy.io/authorize?provider=google`. /// The deep link looks like `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`. /// /// /// # Parameters /// - `provider`: A reference to an `OAuthProvider` indicating which OAuth provider to use for login. /// - `redirect_to`: An optional `String` containing the URL to redirect to after the user is authenticated. /// /// # Returns /// - `Ok(String)`: A `String` containing the constructed authorization URL if the specified provider is available. /// - `Err(AppResponseError)`: An `AppResponseError` indicating either the OAuth provider is invalid or other issues occurred while fetching settings. /// pub async fn generate_url_with_provider_and_redirect_to( &self, provider: &AuthProvider, redirect_to: Option, ) -> Result { let settings = self.gotrue_client.settings().await?; if !settings.external.has_provider(provider) { return Err(AppError::InvalidOAuthProvider(provider.as_str().to_owned()).into()); } let url = format!("{}/authorize", self.gotrue_client.base_url,); let mut url = Url::parse(&url)?; url .query_pairs_mut() .append_pair("provider", provider.as_str()) .append_pair( "redirect_to", redirect_to .unwrap_or_else(|| DESKTOP_CALLBACK_URL.to_string()) .as_str(), ); if let AuthProvider::Google = provider { url .query_pairs_mut() // In many cases, especially for server-side applications or mobile apps that might need to // interact with Google services on behalf of the user without the user being actively // engaged, access_type=offline is preferred to ensure long-term access. .append_pair("access_type", "offline") // In Google OAuth2.0, the prompt parameter is used to control the OAuth2.0 flow's behavior. // It determines if the user is re-prompted for authentication and/or consent. // 1. none: The authorization server does not display any authentication or consent user interface pages. // 2. consent: The authorization server prompts the user for consent before returning information to the client // 3. select_account: The authorization server prompts the user to select a user account. .append_pair("prompt", "consent"); } Ok(url.to_string()) } /// Generates a sign action link for the specified email address. /// This is only applicable if user token is with admin privilege. /// This action link is used on web browser to sign in. When user then click the action link in the browser, /// which calls gotrue authentication server, which then redirects to the appflowy-flutter:// with the authentication token. /// /// [Self::extract_sign_in_url] simulates the browser behavior to extract the sign in url. /// #[instrument(level = "debug", skip_all, err)] pub async fn generate_sign_in_action_link( &self, email: &str, ) -> Result { let admin_user_params: GenerateLinkParams = GenerateLinkParams { email: email.to_string(), ..Default::default() }; let link_resp = self .gotrue_client .admin_generate_link(&self.access_token()?, &admin_user_params) .await?; assert_eq!(link_resp.email, email); Ok(link_resp.action_link) } #[cfg(feature = "test_util")] /// Used to extract the sign in url from the action link /// Only expose this method for testing pub async fn extract_sign_in_url(&self, action_link: &str) -> Result { let resp = reqwest::Client::new().get(action_link).send().await?; let html = resp.text().await.unwrap(); trace!("action_link:{}, html: {}", action_link, html); let fragment = scraper::Html::parse_fragment(&html); let selector = scraper::Selector::parse("a").unwrap(); let sign_in_url = fragment .select(&selector) .next() .unwrap() .value() .attr("href") .unwrap() .to_string(); Ok(sign_in_url) } #[inline] #[instrument(level = "info", skip_all, err)] async fn verify_token(&self, access_token: &str) -> Result<(User, bool), AppResponseError> { let user = self.gotrue_client.user_info(access_token).await?; let is_new = self.verify_token_cloud(access_token).await?; Ok((user, is_new)) } #[instrument(level = "info", skip_all, err)] #[inline] async fn verify_token_cloud(&self, access_token: &str) -> Result { let url = format!("{}/api/user/verify/{}", self.base_url, access_token); let resp = self.cloud_client.get(&url).send().await?; let sign_in_resp: SignInTokenResponse = process_response_data::(resp).await?; Ok(sign_in_resp.is_new) } // Invites another user by sending a magic link to the user's email address. #[instrument(level = "info", skip_all, err)] pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> { self .gotrue_client .magic_link( &MagicLinkParams { email: email.to_owned(), ..Default::default() }, None, ) .await?; Ok(()) } #[instrument(level = "info", skip_all, err)] pub async fn create_user(&self, email: &str, password: &str) -> Result { Ok( self .gotrue_client .admin_add_user( &self.access_token()?, &AdminUserParams { email: email.to_owned(), password: Some(password.to_owned()), email_confirm: true, ..Default::default() }, ) .await?, ) } #[instrument(level = "info", skip_all, err)] pub async fn create_email_verified_user( &self, email: &str, password: &str, ) -> Result { Ok( self .gotrue_client .admin_add_user( &self.access_token()?, &AdminUserParams { email: email.to_owned(), password: Some(password.to_owned()), email_confirm: true, ..Default::default() }, ) .await?, ) } // filter is postgre sql like filter #[instrument(level = "debug", skip_all, err)] pub async fn admin_list_users( &self, filter: Option<&str>, ) -> Result, AppResponseError> { let user = self .gotrue_client .admin_list_user(&self.access_token()?, filter) .await?; Ok(user.users) } /// Only expose this method for testing pub fn token(&self) -> Arc> { self.token.clone() } /// Retrieves the expiration timestamp of the current token. /// /// This function attempts to read the current token and, if successful, returns the expiration timestamp. /// /// # Returns /// - `Ok(i64)`: An `i64` representing the expiration timestamp of the token in seconds. /// - `Err(AppError)`: An `AppError` indicating either an inability to read the token or that the user is not logged in. /// #[inline] pub fn token_expires_at(&self) -> Result { match &self.token.try_read() { None => Err(AppError::Unhandled("Failed to read token".to_string()).into()), Some(token) => Ok( token .as_ref() .ok_or(AppResponseError::from(AppError::NotLoggedIn( "token is empty".to_string(), )))? .expires_at, ), } } /// Retrieves the access token string. /// /// This function attempts to read the current token and, if successful, returns the access token string. /// /// # Returns /// - `Ok(String)`: A `String` containing the access token. /// - `Err(AppResponseError)`: An `AppResponseError` indicating either an inability to read the token or that the user is not logged in. /// pub fn access_token(&self) -> Result { match &self.token.try_read_for(Duration::from_secs(2)) { None => Err(AppError::Unhandled("Failed to read token".to_string()).into()), Some(token) => { let access_token = token .as_ref() .ok_or(AppResponseError::from(AppError::NotLoggedIn( "fail to get access token. Token is empty".to_string(), )))? .access_token .clone(); if access_token.is_empty() { error!("Unexpected empty access token"); } Ok(access_token) }, } } #[instrument(level = "info", skip_all, err)] pub async fn get_profile(&self) -> Result { let url = format!("{}/api/user/profile", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_user_workspace_info(&self) -> Result { let url = format!("{}/api/user/workspace", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn create_workspace( &self, params: CreateWorkspaceParam, ) -> Result { let url = format!("{}/api/workspace", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn patch_workspace(&self, params: PatchWorkspaceParam) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace", self.base_url); let resp = self .http_client_with_auth(Method::PATCH, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } pub async fn get_workspaces(&self) -> Result, AppResponseError> { self .get_workspaces_opt(QueryWorkspaceParam::default()) .await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspaces_opt( &self, param: QueryWorkspaceParam, ) -> Result, AppResponseError> { let url = format!("{}/api/workspace", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(¶m) .send() .await?; process_response_data::>(resp).await } /// List out the views in the workspace recursively. /// The depth parameter specifies the depth of the folder view tree to return(default: 1). /// e.g., depth=1 will return only up to `Shared` and `PrivateSpace` /// depth=2 will return up to `mydoc1`, `mydoc2`, `mydoc3`, `mydoc4` /// /// . MyWorkspace /// ├── Shared /// │ ├── mydoc1 /// │ └── mydoc2 /// └── PrivateSpace /// ├── mydoc3 /// └── mydoc4 #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_folder( &self, workspace_id: &Uuid, depth: Option, root_view_id: Option, ) -> Result { let url = format!("{}/api/workspace/{}/folder", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&QueryWorkspaceFolder { depth, root_view_id, }) .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn open_workspace(&self, workspace_id: &Uuid) -> Result { let url = format!("{}/api/workspace/{}/open", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_favorite( &self, workspace_id: &Uuid, ) -> Result { let url = format!("{}/api/workspace/{}/favorite", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_recent( &self, workspace_id: &Uuid, ) -> Result { let url = format!("{}/api/workspace/{}/recent", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_trash( &self, workspace_id: &Uuid, ) -> Result { let url = format!("{}/api/workspace/{}/trash", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn join_workspace_by_invitation_code( &self, invitation_code: &str, ) -> Result { let url = format!("{}/api/workspace/join-by-invite-code", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&JoinWorkspaceByInviteCodeParams { code: invitation_code.to_string(), }) .send() .await?; process_response_data::(resp).await } pub async fn get_invitation_code_info( &self, invitation_code: &str, ) -> Result { let url = format!("{}/api/invite-code-info", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&GetInvitationCodeInfoQuery { code: invitation_code.to_string(), }) .send() .await?; process_response_data::(resp).await } pub async fn create_workspace_invitation_code( &self, workspace_id: &Uuid, params: &WorkspaceInviteCodeParams, ) -> Result { let url = format!( "{}/api/workspace/{}/invite-code", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(params) .send() .await?; process_response_data::(resp).await } pub async fn get_workspace_invitation_code( &self, workspace_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/invite-code", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn delete_workspace_invitation_code( &self, workspace_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/invite-code", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn sign_up(&self, email: &str, password: &str) -> Result<(), AppResponseError> { match self.gotrue_client.sign_up(email, password, None).await? { Authenticated(access_token_resp) => { self.token.write().set(access_token_resp.clone()); Ok(()) }, NotAuthenticated(user) => { tracing::info!("sign_up but not authenticated: {}", user.email); Ok(()) }, } } #[instrument(level = "info", skip_all)] pub async fn sign_out(&self) -> Result<(), AppResponseError> { self.gotrue_client.logout(&self.access_token()?).await?; self.token.write().unset(); Ok(()) } #[instrument(level = "info", skip_all, err)] pub async fn update_user(&self, params: UpdateUserParams) -> Result<(), AppResponseError> { let gotrue_params = UpdateGotrueUserParams::new() .with_opt_email(params.email.clone()) .with_opt_password(params.password.clone()); let updated_user = self .gotrue_client .update_user(&self.access_token()?, &gotrue_params) .await?; if let Some(token) = self.token.write().as_mut() { token.user = updated_user; } let url = format!("{}/api/user/update", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn delete_user(&self) -> Result<(), AppResponseError> { let (provider_access_token, provider_refresh_token) = { let token = &self.token; let token_read = token.read(); let token_resp = token_read .as_ref() .ok_or(AppResponseError::from(AppError::NotLoggedIn( "token is empty".to_string(), )))?; ( token_resp.provider_access_token.clone(), token_resp.provider_refresh_token.clone(), ) }; let url = format!("{}/api/user", self.base_url); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .query(&DeleteUserQuery { provider_access_token, provider_refresh_token, }) .send() .await?; process_response_error(resp).await } pub async fn ws_connect_info(&self, auto_refresh: bool) -> Result { if auto_refresh { self .refresh_if_expired( chrono::Local::now().timestamp(), "get websocket connect info", ) .await?; } Ok(ConnectInfo { access_token: self.access_token()?, client_version: self.client_version.clone(), device_id: self.device_id.clone(), }) } #[instrument(level = "info", skip_all)] pub async fn get_workspace_usage( &self, workspace_id: &Uuid, ) -> Result { let url = format!("{}/api/file_storage/{}/usage", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all)] pub async fn get_server_info(&self) -> Result { let url = format!("{}/api/server", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; if resp.status() == StatusCode::NOT_FOUND { Err(AppResponseError::new( ErrorCode::Unhandled, "server info not implemented", )) } else { process_response_data::(resp).await } } /// Refreshes the access token using the stored refresh token. /// /// attempts to refresh the access token by sending a request to the authentication server /// using the stored refresh token. If successful, it updates the stored access token with the new one /// received from the server. /// Refreshes the access token using the stored refresh token. #[instrument(level = "debug", skip_all, err)] pub async fn refresh_token(&self, reason: &str) -> Result<(), AppResponseError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.refresh_ret_txs.write().push(tx); // Atomically check and set the refreshing flag to prevent race conditions if self .is_refreshing_token .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { info!("refresh token reason:{}", reason); let result = self.inner_refresh_token().await; // Process all pending requests and reset state atomically let mut txs_guard = self.refresh_ret_txs.write(); let txs = std::mem::take(&mut *txs_guard); self.is_refreshing_token.store(false, Ordering::SeqCst); drop(txs_guard); // Send results to all waiting requests for tx in txs { let _ = tx.send(result.clone()); } } else { debug!("refresh token is already in progress"); } // Wait for the result of the refresh token request. match tokio::time::timeout(Duration::from_secs(60), rx).await { Ok(Ok(result)) => result, Ok(Err(err)) => { Err(AppError::Internal(anyhow!("refresh token channel error: {}", err)).into()) }, Err(_) => Err(AppError::RequestTimeout("refresh token timeout".to_string()).into()), } } async fn inner_refresh_token(&self) -> Result<(), AppResponseError> { let retry_strategy = FixedInterval::new(Duration::from_secs(2)).take(4); let action = RefreshTokenAction::new(self.token.clone(), self.gotrue_client.clone()); match RetryIf::spawn(retry_strategy, action, RefreshTokenRetryCondition).await { Ok(_) => { event!(tracing::Level::INFO, "refresh token success"); Ok(()) }, Err(err) => { let err = AppError::from(err); event!(tracing::Level::ERROR, "refresh token failed: {}", err); // If the error is an OAuth error, unset the token. if err.is_unauthorized() { self.token.write().unset(); } Err(err.into()) }, } } // Refresh token if given timestamp is close to the token expiration time pub async fn refresh_if_expired(&self, ts: i64, reason: &str) -> Result<(), AppResponseError> { let expires_at = self.token_expires_at()?; if ts + 30 > expires_at { info!("token is about to expire, refreshing token"); // Add 30 seconds buffer self.refresh_token(reason).await?; } Ok(()) } #[instrument(level = "debug", skip_all, err)] pub async fn http_client_without_auth( &self, method: Method, url: &str, ) -> Result { trace!("start request: {}, method: {}", url, method,); Ok(self.cloud_client.request(method, url)) } #[instrument(level = "debug", skip_all, err)] pub async fn http_client_with_auth( &self, method: Method, url: &str, ) -> Result { let ts_now = chrono::Local::now().timestamp(); self .refresh_if_expired(ts_now, "make http client request") .await?; let access_token = self.access_token()?; let headers = [ ("client-version", self.client_version.to_string()), ("client-timestamp", ts_now.to_string()), ("device-id", self.device_id.clone()), ]; trace!( "start request: {}, method: {}, headers: {:?}", url, method, headers ); let mut request_builder = self .cloud_client .request(method, url) .bearer_auth(access_token); for header in headers { request_builder = request_builder.header(header.0, header.1); } Ok(request_builder) } #[instrument(level = "debug", skip_all, err)] pub async fn http_client_with_model( &self, method: Method, url: &str, ai_model: Option, ) -> Result { let mut builder = self.http_client_with_auth(method, url).await?; let effective_ai_model = match ai_model { Some(model) => model, None => self.ai_model.read().clone(), }; builder = builder.header("ai-model", effective_ai_model); Ok(builder) } #[instrument(level = "debug", skip_all, err)] pub(crate) async fn http_client_with_auth_compress( &self, method: Method, url: &str, ) -> Result { #[cfg(feature = "enable_brotli")] { self .http_client_with_auth(method, url) .await .map(|builder| { builder .header( crate::http::X_COMPRESSION_TYPE, reqwest::header::HeaderValue::from_static(crate::http::X_COMPRESSION_TYPE_BROTLI), ) .header( crate::http::X_COMPRESSION_BUFFER_SIZE, reqwest::header::HeaderValue::from(self.config.compression_buffer_size), ) }) } #[cfg(not(feature = "enable_brotli"))] self.http_client_with_auth(method, url).await } #[instrument(level = "info", skip_all)] pub(crate) fn batch_create_collab_url(&self, workspace_id: &Uuid) -> String { format!( "{}/api/workspace/{}/batch/collab", self.base_url, workspace_id ) } } impl Display for Client { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "Client {{ base_url: {}, ws_addr: {}, gotrue_url: {} }}", self.base_url, self.ws_addr, self.gotrue_client.base_url )) } } fn url_missing_param(param: &str) -> AppResponseError { AppError::InvalidRequest(format!("Url Missing Parameter:{}", param)).into() } #[cfg(feature = "enable_brotli")] pub fn brotli_compress( data: Vec, quality: u32, buffer_size: usize, ) -> Result, AppError> { let mut compressor = brotli::CompressorReader::new(&*data, buffer_size, quality, 22); let mut compressed_data = Vec::new(); compressor .read_to_end(&mut compressed_data) .map_err(|err| AppError::InvalidRequest(format!("Failed to compress data: {}", err)))?; Ok(compressed_data) } #[cfg(feature = "enable_brotli")] pub async fn blocking_brotli_compress( data: Vec, quality: u32, buffer_size: usize, ) -> Result, AppError> { tokio::task::spawn_blocking(move || brotli_compress(data, quality, buffer_size)) .await .map_err(AppError::from)? } #[cfg(not(feature = "enable_brotli"))] pub async fn blocking_brotli_compress( data: Vec, _quality: u32, _buffer_size: usize, ) -> Result, AppError> { Ok(data) } #[cfg(not(feature = "enable_brotli"))] pub fn brotli_compress( data: Vec, _quality: u32, _buffer_size: usize, ) -> Result, AppError> { Ok(data) } fn attach_request_id( mut err: AppResponseError, request_id: impl std::fmt::Debug, ) -> AppResponseError { err.message = Cow::Owned(format!("{}. request_id: {:?}", err.message, request_id)); err } pub async fn process_response_data(resp: reqwest::Response) -> Result where T: serde::de::DeserializeOwned + 'static, { let request_id = extract_request_id(&resp); AppResponse::::from_response(resp) .await .map_err(|err| { error!( "Error parsing response, request_id: {:?}, error: {}", request_id, err ); AppResponseError::from(err) }) .and_then(|app_response| { app_response .into_data() .map_err(|err| attach_request_id(err, &request_id)) }) } pub async fn process_response_error(resp: reqwest::Response) -> Result<(), AppResponseError> { let request_id = extract_request_id(&resp); AppResponse::<()>::from_response(resp) .await? .into_error() .map_err(|err| attach_request_id(err, &request_id)) } fn extract_request_id(resp: &reqwest::Response) -> Option { resp .headers() .get("x-request-id") .map(|v| v.to_str().unwrap_or("invalid").to_string()) } ================================================ FILE: libs/client-api/src/http_access_request.rs ================================================ use client_api_entity::{ access_request_dto::AccessRequest, AccessRequestMinimal, ApproveAccessRequestParams, CreateAccessRequestParams, }; use reqwest::Method; use shared_entity::response::AppResponseError; use uuid::Uuid; use crate::{process_response_data, process_response_error, Client}; impl Client { pub async fn get_access_request( &self, access_request_id: Uuid, ) -> Result { let url = format!("{}/api/access-request/{}", self.base_url, access_request_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn create_access_request( &self, data: CreateAccessRequestParams, ) -> Result { let url = format!("{}/api/access-request", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&data) .send() .await?; process_response_data::(resp).await } pub async fn approve_access_request( &self, access_request_id: Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/access-request/{}/approve", self.base_url, access_request_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&ApproveAccessRequestParams { is_approved: true }) .send() .await?; process_response_error(resp).await } pub async fn reject_access_request( &self, access_request_id: Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/access-request/{}/approve", self.base_url, access_request_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&ApproveAccessRequestParams { is_approved: false }) .send() .await?; process_response_error(resp).await } } ================================================ FILE: libs/client-api/src/http_ai.rs ================================================ use crate::http_chat::CompletionStream; use crate::{process_response_data, Client}; use bytes::Bytes; use futures_core::Stream; use reqwest::Method; use shared_entity::dto::ai_dto::{ CompleteTextParams, LocalAIConfig, ModelList, SummarizeRowParams, SummarizeRowResponse, TranslateRowParams, TranslateRowResponse, }; use shared_entity::response::{AppResponse, AppResponseError}; use std::time::Duration; use tracing::instrument; use uuid::Uuid; impl Client { pub async fn stream_completion_text( &self, workspace_id: &str, params: CompleteTextParams, ) -> Result>, AppResponseError> { let url = format!("{}/api/ai/{}/complete/stream", self.base_url, workspace_id); let resp = self .http_client_with_model(Method::POST, &url, None) .await? .json(¶ms) .send() .await?; AppResponse::<()>::answer_response_stream(resp).await } pub async fn stream_completion_v2( &self, workspace_id: &Uuid, params: CompleteTextParams, ai_model: Option, ) -> Result { let url = format!( "{}/api/ai/{}/v2/complete/stream", self.base_url, workspace_id ); let resp = self .http_client_with_model(Method::POST, &url, ai_model) .await? .json(¶ms) .send() .await?; let stream = AppResponse::::json_response_stream(resp).await?; Ok(CompletionStream::new(stream)) } #[instrument(level = "info", skip_all)] pub async fn summarize_row( &self, params: SummarizeRowParams, ) -> Result { let url = format!( "{}/api/ai/{}/summarize_row", self.base_url, params.workspace_id ); let resp = self .http_client_with_model(Method::POST, &url, None) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all)] pub async fn translate_row( &self, params: TranslateRowParams, ) -> Result { let url = format!( "{}/api/ai/{}/translate_row", self.base_url, params.workspace_id ); let resp = self .http_client_with_model(Method::POST, &url, None) .await? .json(¶ms) .timeout(Duration::from_secs(30)) .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_local_ai_config( &self, workspace_id: &str, platform: &str, ) -> Result { let client_version = self.client_version.to_string(); let url = format!( "{}/api/ai/{}/local/config?platform={platform}&app_version={client_version}", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "debug", skip_all, err)] pub async fn get_model_list(&self, workspace_id: &Uuid) -> Result { let url = format!("{}/api/ai/{workspace_id}/model/list", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_billing.rs ================================================ use crate::{process_response_data, process_response_error, Client}; use client_api_entity::billing_dto::{ SetSubscriptionRecurringInterval, SubscriptionCancelRequest, SubscriptionLinkRequest, SubscriptionPlanDetail, WorkspaceUsageAndLimit, }; use reqwest::Method; use shared_entity::{ dto::billing_dto::{RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus}, response::{AppResponse, AppResponseError}, }; lazy_static::lazy_static! { static ref BASE_BILLING_URL: Option = match std::env::var("APPFLOWY_CLOUD_BASE_BILLING_URL") { Ok(url) => Some(url), Err(err) => { tracing::warn!("std::env::var(APPFLOWY_CLOUD_BASE_BILLING_URL): {}", err); None }, }; } impl Client { pub fn base_billing_url(&self) -> &str { BASE_BILLING_URL.as_deref().unwrap_or(&self.base_url) } pub async fn customer_id(&self) -> Result { let url = format!("{}/billing/api/v1/customer-id", self.base_billing_url()); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn create_subscription( &self, workspace_id: &str, recurring_interval: RecurringInterval, workspace_subscription_plan: SubscriptionPlan, success_url: &str, ) -> Result { let sub_link_req = SubscriptionLinkRequest { workspace_subscription_plan, recurring_interval, workspace_id: workspace_id.to_string(), success_url: success_url.to_string(), with_test_clock: None, }; self.create_subscription_v2(&sub_link_req).await } pub async fn create_subscription_v2( &self, sub_link_req: &SubscriptionLinkRequest, ) -> Result { let url = format!( "{}/billing/api/v1/subscription-link", self.base_billing_url() ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(sub_link_req) .send() .await?; process_response_data::(resp).await } pub async fn cancel_subscription( &self, req: &SubscriptionCancelRequest, ) -> Result<(), AppResponseError> { let url = format!( "{}/billing/api/v1/cancel-subscription", self.base_billing_url() ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(req) .send() .await?; process_response_error(resp).await } pub async fn list_subscription( &self, ) -> Result, AppResponseError> { let url = format!( "{}/billing/api/v1/subscription-status", self.base_billing_url(), ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } pub async fn get_portal_session_link(&self) -> Result { let url = format!( "{}/billing/api/v1/portal-session-link", self.base_billing_url() ); let portal_url = self .http_client_with_auth(Method::GET, &url) .await? .send() .await? .error_for_status()? .json::>() .await? .into_data()?; Ok(portal_url) } pub async fn get_workspace_usage_and_limit( &self, workspace_id: &str, ) -> Result { let url = format!( "{}/api/workspace/{}/usage-and-limit", self.base_url, workspace_id ); self .http_client_with_auth(Method::GET, &url) .await? .send() .await? .error_for_status()? .json::>() .await? .into_data() } /// Query all subscription status for a workspace pub async fn get_workspace_subscriptions( &self, workspace_id: &str, ) -> Result, AppResponseError> { let url = format!( "{}/billing/api/v1/subscription-status/{}", self.base_billing_url(), workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } /// Query all active subscription, minimal information but faster pub async fn get_active_workspace_subscriptions( &self, workspace_id: &str, ) -> Result, AppResponseError> { let url = format!( "{}/billing/api/v1/active-subscription/{}", self.base_billing_url(), workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } /// Set subscription recurring interval pub async fn set_subscription_recurring_interval( &self, set_sub_recur: &SetSubscriptionRecurringInterval, ) -> Result<(), AppResponseError> { let url = format!( "{}/billing/api/v1/subscription-recurring-interval", self.base_billing_url(), ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(set_sub_recur) .send() .await?; process_response_error(resp).await } /// get all subscription plan details pub async fn get_subscription_plan_details( &self, ) -> Result, AppResponseError> { let url = format!("{}/billing/api/v1/subscriptions", self.base_billing_url(),); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } } ================================================ FILE: libs/client-api/src/http_blob.rs ================================================ use crate::{process_response_data, process_response_error, Client}; use app_error::AppError; use bytes::Bytes; use futures_util::TryStreamExt; use mime::Mime; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use reqwest::{header, Method, StatusCode}; use shared_entity::dto::workspace_dto::{BlobMetadata, RepeatedBlobMetaData}; use shared_entity::response::AppResponseError; use shared_entity::dto::file_dto::PutFileResponse; use tracing::instrument; use url::Url; use uuid::Uuid; impl Client { pub fn get_blob_url(&self, workspace_id: &Uuid, file_id: &str) -> String { format!( "{}/api/file_storage/{}/blob/{}", self.base_url, workspace_id, file_id ) } #[instrument(level = "info", skip_all)] pub async fn put_blob>( &self, url: &str, data: T, mime: &Mime, ) -> Result<(), AppResponseError> { let data = data.into(); let resp = self .http_client_with_auth(Method::PUT, url) .await? .header(header::CONTENT_TYPE, mime.to_string()) .body(data) .send() .await?; if resp.status() == StatusCode::PAYLOAD_TOO_LARGE { return Err(AppResponseError::from(AppError::PayloadTooLarge( StatusCode::PAYLOAD_TOO_LARGE.to_string(), ))); } process_response_error(resp).await } #[instrument(level = "info", skip_all)] pub async fn put_blob_v1>( &self, workspace_id: &Uuid, parent_dir: &str, data: T, mime: &Mime, ) -> Result { let url = format!( "{}/api/file_storage/{}/v1/blob/{}", self.base_url, workspace_id, parent_dir ); let data = data.into(); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .header(header::CONTENT_TYPE, mime.to_string()) .body(data) .send() .await?; if resp.status() == StatusCode::PAYLOAD_TOO_LARGE { return Err(AppResponseError::from(AppError::PayloadTooLarge( StatusCode::PAYLOAD_TOO_LARGE.to_string(), ))); } process_response_data::(resp).await } /// Only expose this method for testing #[instrument(level = "info", skip_all)] #[cfg(debug_assertions)] pub async fn put_blob_with_content_length>( &self, url: &str, data: T, mime: &Mime, content_length: usize, ) -> Result { let resp = self .http_client_with_auth(Method::PUT, url) .await? .header(header::CONTENT_TYPE, mime.to_string()) .header(header::CONTENT_LENGTH, content_length) .body(data.into()) .send() .await?; process_response_data::(resp).await } pub fn get_blob_url_v1(&self, workspace_id: &Uuid, parent_dir: &str, file_id: &str) -> String { let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string(); format!( "{}/api/file_storage/{workspace_id}/v1/blob/{parent_dir}/{file_id}", self.base_url ) } /// Returns the workspace_id, parent_dir, and file_id from the given blob url. pub fn parse_blob_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> { let parsed_url = Url::parse(url).ok()?; let segments: Vec<&str> = parsed_url.path_segments()?.collect(); // Check if the path has the expected number of segments if segments.len() < 6 { return None; } // Extract the workspace_id, parent_dir, and file_id from the segments let workspace_id: Uuid = segments[2].parse().ok()?; let encoded_parent_dir = segments[5].to_string(); let file_id = segments[6].to_string(); // Decode the percent-encoded parent_dir let parent_dir = percent_decode_str(&encoded_parent_dir) .decode_utf8() .ok()? .to_string(); Some((workspace_id, parent_dir, file_id)) } #[instrument(level = "info", skip_all)] pub async fn get_blob_v1( &self, workspace_id: &Uuid, parent_dir: &str, file_id: &str, ) -> Result<(Mime, Vec), AppResponseError> { // Encode the parent directory to ensure it's URL-safe. let url = self.get_blob_url_v1(workspace_id, parent_dir, file_id); self.get_blob(&url).await } #[instrument(level = "info", skip_all)] pub async fn delete_blob_v1( &self, workspace_id: &str, parent_dir: &str, file_id: &str, ) -> Result<(), AppResponseError> { let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string(); let url = format!( "{}/api/file_storage/{workspace_id}/v1/blob/{parent_dir}/{file_id}", self.base_url ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all)] pub async fn get_blob_v1_metadata( &self, workspace_id: &str, parent_dir: &str, file_id: &str, ) -> Result { let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string(); let url = format!( "{}/api/file_storage/{workspace_id}/v1/metadata/{parent_dir}/{file_id}", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } /// Get the file with the given url. The url should be in the format of /// `https://appflowy.io/api/file_storage//`. #[instrument(level = "info", skip_all)] pub async fn get_blob(&self, url: &str) -> Result<(Mime, Vec), AppResponseError> { let resp = self .http_client_with_auth(Method::GET, url) .await? .send() .await?; match resp.status() { StatusCode::OK => { let mime = resp .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .unwrap_or(mime::TEXT_PLAIN); let bytes = resp .bytes_stream() .try_fold(Vec::new(), |mut acc, chunk| async move { acc.extend_from_slice(&chunk); Ok(acc) }) .await?; Ok((mime, bytes)) }, StatusCode::NOT_FOUND => Err(AppResponseError::from(AppError::RecordNotFound( url.to_owned(), ))), status => { let message = resp .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); Err(AppResponseError::from(AppError::Unhandled(format!( "status code: {}, message: {}", status, message )))) }, } } #[instrument(level = "info", skip_all)] pub async fn get_blob_metadata(&self, url: &str) -> Result { let resp = self .http_client_with_auth(Method::GET, url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all)] pub async fn delete_blob(&self, url: &str) -> Result<(), AppResponseError> { let resp = self .http_client_with_auth(Method::DELETE, url) .await? .send() .await?; process_response_error(resp).await } pub async fn get_workspace_all_blob_metadata( &self, workspace_id: &str, ) -> Result { let url = format!("{}/api/file_storage/{}/blobs", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_chat.rs ================================================ use crate::{process_response_data, process_response_error, Client}; use app_error::AppError; use client_api_entity::chat_dto::{ ChatMessage, CreateAnswerMessageParams, CreateChatMessageParams, CreateChatParams, MessageCursor, RepeatedChatMessage, RepeatedChatMessageWithAuthorUuid, UpdateChatMessageContentParams, }; use futures_core::{ready, Stream}; use pin_project::pin_project; use reqwest::Method; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_entity::dto::ai_dto::{ CalculateSimilarityParams, ChatQuestionQuery, RepeatedRelatedQuestion, SimilarityResponse, STREAM_ANSWER_KEY, STREAM_COMMENT_KEY, STREAM_IMAGE_KEY, STREAM_METADATA_KEY, }; use shared_entity::dto::chat_dto::{ChatSettings, UpdateChatParams}; use shared_entity::response::{AppResponse, AppResponseError}; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; use tracing::error; use uuid::Uuid; impl Client { /// Create a new chat pub async fn create_chat( &self, workspace_id: &Uuid, params: CreateChatParams, ) -> Result<(), AppResponseError> { let url = format!("{}/api/chat/{workspace_id}", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } pub async fn update_chat_settings( &self, workspace_id: &Uuid, chat_id: &str, params: UpdateChatParams, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/settings", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } pub async fn get_chat_settings( &self, workspace_id: &Uuid, chat_id: &str, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/settings", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } /// Delete a chat for given chat_id pub async fn delete_chat( &self, workspace_id: &Uuid, chat_id: &str, ) -> Result<(), AppResponseError> { let url = format!("{}/api/chat/{workspace_id}/{chat_id}", self.base_url); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } /// Save a question message to a chat pub async fn create_question( &self, workspace_id: &Uuid, chat_id: &str, params: CreateChatMessageParams, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message/question", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } /// save an answer message to a chat pub async fn save_answer( &self, workspace_id: &Uuid, chat_id: &str, params: CreateAnswerMessageParams, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message/answer", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } pub async fn stream_answer_v2( &self, workspace_id: &Uuid, chat_id: &str, question_id: i64, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/{question_id}/v2/answer/stream", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .timeout(Duration::from_secs(30)) .send() .await .map_err(|err| { let app_err = AppError::from(err); if matches!(app_err, AppError::ServiceTemporaryUnavailable(_)) { AppError::AIServiceUnavailable( "AI service temporarily unavailable, please try again later".to_string(), ) } else { app_err } })?; let stream = AppResponse::::json_response_stream(resp).await?; Ok(QuestionStream::new(stream)) } pub async fn stream_answer_v3( &self, workspace_id: &Uuid, query: ChatQuestionQuery, chat_model: Option, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{}/answer/stream", self.base_url, query.chat_id ); let resp = self .http_client_with_model(Method::POST, &url, chat_model) .await? .timeout(Duration::from_secs(60)) .json(&query) .send() .await?; let stream = AppResponse::::json_response_stream(resp).await?; Ok(QuestionStream::new(stream)) } pub async fn get_answer( &self, workspace_id: &Uuid, chat_id: &str, question_message_id: i64, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/{question_message_id}/answer", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } /// Update chat message content. It will override the content of the message. /// A message can be a question or an answer pub async fn update_chat_message( &self, workspace_id: &Uuid, chat_id: &str, params: UpdateChatMessageContentParams, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message", self.base_url ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } /// Get related question for a chat message. The message_d should be the question's id pub async fn get_chat_related_question( &self, workspace_id: &Uuid, chat_id: &str, message_id: i64, ) -> Result { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/{message_id}/related_question", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } /// Deprecated since v0.9.24. Return list of chat messages for a chat pub async fn get_chat_messages( &self, workspace_id: &Uuid, chat_id: &str, offset: MessageCursor, limit: u64, ) -> Result { let mut url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message", self.base_url ); let mut query_params = vec![("limit", limit.to_string())]; match offset { MessageCursor::Offset(offset_value) => { query_params.push(("offset", offset_value.to_string())); }, MessageCursor::AfterMessageId(message_id) => { query_params.push(("after", message_id.to_string())); }, MessageCursor::BeforeMessageId(message_id) => { query_params.push(("before", message_id.to_string())); }, MessageCursor::NextBack => {}, } let query = serde_urlencoded::to_string(&query_params).unwrap(); url = format!("{}?{}", url, query); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } /// Return list of chat messages for a chat. Each message will have author_uuid as /// as the author's uid, as author_uid will face precision issue in the browser environment. pub async fn get_chat_messages_with_author_uuid( &self, workspace_id: &Uuid, chat_id: &str, offset: MessageCursor, limit: u64, ) -> Result { let mut url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message", self.base_url ); let mut query_params = vec![("limit", limit.to_string())]; match offset { MessageCursor::Offset(offset_value) => { query_params.push(("offset", offset_value.to_string())); }, MessageCursor::AfterMessageId(message_id) => { query_params.push(("after", message_id.to_string())); }, MessageCursor::BeforeMessageId(message_id) => { query_params.push(("before", message_id.to_string())); }, MessageCursor::NextBack => {}, } let query = serde_urlencoded::to_string(&query_params).unwrap(); url = format!("{}?{}", url, query); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn get_question_message_from_answer_id( &self, workspace_id: &Uuid, chat_id: &str, answer_message_id: i64, ) -> Result, AppResponseError> { let url = format!( "{}/api/chat/{workspace_id}/{chat_id}/message/find_question", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&[("answer_message_id", answer_message_id)]) .send() .await?; process_response_data::>(resp).await } pub async fn calculate_similarity( &self, params: CalculateSimilarityParams, ) -> Result { let url = format!( "{}/api/ai/{}/calculate_similarity", self.base_url, ¶ms.workspace_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } } #[pin_project] pub struct QuestionStream { stream: Pin> + Send>>, buffer: Vec, } impl QuestionStream { pub fn new(stream: S) -> Self where S: Stream> + Send + 'static, { QuestionStream { stream: Box::pin(stream), buffer: Vec::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum QuestionStreamValue { Answer { value: String, }, /// Metadata is a JSON array object. its structure as below: /// ```json /// [ /// {"id": "xx", "source": "", "name": "" } /// ] Metadata { value: Value, }, SuggestedQuestion { context_suggested_questions: Vec, }, FollowUp { should_generate_related_question: bool, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextSuggestedQuestion { pub content: String, pub object_id: String, } impl Stream for QuestionStream { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.project(); match ready!(this.stream.as_mut().poll_next(cx)) { Some(Ok(value)) => match value { Value::Object(mut value) => { if let Some(metadata) = value.remove(STREAM_METADATA_KEY) { return Poll::Ready(Some(Ok(QuestionStreamValue::Metadata { value: metadata }))); } if let Some(answer) = value .remove(STREAM_ANSWER_KEY) .and_then(|s| s.as_str().map(ToString::to_string)) { return Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: answer }))); } if let Some(image) = value .remove(STREAM_IMAGE_KEY) .and_then(|s| s.as_str().map(ToString::to_string)) { return Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: image }))); } error!("Invalid streaming value: {:?}", value); Poll::Ready(None) }, _ => { error!("Unexpected JSON value type: {:?}", value); Poll::Ready(None) }, }, Some(Err(err)) => { error!("Error while streaming answer: {:?}", err); Poll::Ready(Some(Err(err))) }, None => Poll::Ready(None), } } } #[pin_project] pub struct CompletionStream { stream: Pin> + Send>>, buffer: Vec, } impl CompletionStream { pub fn new(stream: S) -> Self where S: Stream> + Send + 'static, { CompletionStream { stream: Box::pin(stream), buffer: Vec::new(), } } } #[derive(Debug, Clone)] pub enum CompletionStreamValue { Answer { value: String }, Comment { value: String }, } impl Stream for CompletionStream { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.project(); match ready!(this.stream.as_mut().poll_next(cx)) { Some(Ok(value)) => match value { Value::Object(mut value) => { if let Some(answer) = value .remove(STREAM_ANSWER_KEY) .and_then(|s| s.as_str().map(ToString::to_string)) { return Poll::Ready(Some(Ok(CompletionStreamValue::Answer { value: answer }))); } if let Some(comment) = value .remove(STREAM_COMMENT_KEY) .and_then(|s| s.as_str().map(ToString::to_string)) { return Poll::Ready(Some(Ok(CompletionStreamValue::Comment { value: comment }))); } error!("Invalid streaming value: {:?}", value); Poll::Ready(None) }, _ => { error!("Unexpected JSON value type: {:?}", value); Poll::Ready(None) }, }, Some(Err(err)) => { error!("Error while streaming answer: {:?}", err); Poll::Ready(Some(Err(err))) }, None => Poll::Ready(None), } } } ================================================ FILE: libs/client-api/src/http_collab.rs ================================================ use crate::entity::CollabType; use crate::{ blocking_brotli_compress, brotli_compress, process_response_data, process_response_error, Client, }; use anyhow::anyhow; use app_error::AppError; use bytes::Bytes; use chrono::{DateTime, Utc}; use client_api_entity::workspace_dto::{ AFDatabase, AFDatabaseField, AFDatabaseRow, AFDatabaseRowDetail, AFInsertDatabaseField, AddDatatabaseRow, DatabaseRowUpdatedItem, ListDatabaseRowDetailParam, ListDatabaseRowUpdatedParam, UpsertDatatabaseRow, }; use client_api_entity::{ AFCollabEmbedInfo, AFDatabaseRowDocumentCollabExistenceInfo, BatchQueryCollabParams, BatchQueryCollabResult, CollabParams, CreateCollabData, CreateCollabParams, DeleteCollabParams, PublishCollabItem, QueryCollab, QueryCollabParams, RepeatedAFCollabEmbedInfo, UpdateCollabWebParams, }; use collab_rt_entity::collab_proto::{CollabDocStateParams, PayloadCompressionType}; use collab_rt_entity::HttpRealtimeMessage; use futures::Stream; use futures_util::stream; use prost::Message; use rayon::prelude::*; use reqwest::{Body, Method}; use serde::Serialize; use shared_entity::dto::workspace_dto::{CollabResponse, CollabTypeParam, EmbeddedCollabQuery}; use shared_entity::response::AppResponseError; use std::collections::HashMap; use std::future::Future; use std::io::Cursor; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; use tokio_retry::strategy::ExponentialBackoff; use tokio_retry::{Action, Condition, RetryIf}; use tracing::{event, instrument}; use uuid::Uuid; impl Client { #[instrument(level = "info", skip_all, err)] pub async fn create_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/collab/{}", self.base_url, params.workspace_id, ¶ms.object_id ); let bytes = params .to_bytes() .map_err(|err| AppError::Internal(err.into()))?; let compress_bytes = blocking_brotli_compress( bytes, self.config.compression_quality, self.config.compression_buffer_size, ) .await?; #[allow(unused_mut)] let mut builder = self .http_client_with_auth_compress(Method::POST, &url) .await?; #[cfg(not(target_arch = "wasm32"))] { builder = builder.timeout(std::time::Duration::from_secs(60)); } let resp = builder.body(compress_bytes).send().await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn update_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/collab/{}", self.base_url, ¶ms.workspace_id, ¶ms.object_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } pub async fn update_web_collab( &self, workspace_id: &Uuid, object_id: &Uuid, params: UpdateCollabWebParams, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/v1/{}/collab/{}/web-update", self.base_url, workspace_id, object_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } // The browser will call this API to get the collab list, because the URL length limit and browser can't send the body in GET request #[instrument(level = "info", skip_all, err)] pub async fn batch_post_collab( &self, workspace_id: &Uuid, params: Vec, ) -> Result { self .send_batch_collab_request(Method::POST, workspace_id, params) .await } #[instrument(level = "info", skip_all, err)] pub async fn batch_get_collab( &self, workspace_id: &Uuid, params: Vec, ) -> Result { self .send_batch_collab_request(Method::GET, workspace_id, params) .await } async fn send_batch_collab_request( &self, method: Method, workspace_id: &Uuid, params: Vec, ) -> Result { let url = format!( "{}/api/workspace/{}/collab_list", self.base_url, workspace_id ); let params = BatchQueryCollabParams(params); let resp = self .http_client_with_auth(method, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn delete_collab(&self, params: DeleteCollabParams) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/collab/{}", self.base_url, ¶ms.workspace_id, ¶ms.object_id ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .json(¶ms) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn list_databases( &self, workspace_id: &Uuid, ) -> Result, AppResponseError> { let url = format!("{}/api/workspace/{}/database", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } pub async fn list_database_row_ids( &self, workspace_id: &Uuid, database_id: &str, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{}/database/{}/row", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } pub async fn get_database_fields( &self, workspace_id: &Uuid, database_id: &str, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{}/database/{}/fields", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } // Adds a database field to the specified database. // Returns the field id of the newly created field. pub async fn add_database_field( &self, workspace_id: &Uuid, database_id: &str, insert_field: &AFInsertDatabaseField, ) -> Result { let url = format!( "{}/api/workspace/{}/database/{}/fields", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(insert_field) .send() .await?; process_response_data::(resp).await } pub async fn list_database_row_ids_updated( &self, workspace_id: &Uuid, database_id: &str, after: Option>, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{}/database/{}/row/updated", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&ListDatabaseRowUpdatedParam { after }) .send() .await?; process_response_data::>(resp).await } pub async fn list_database_row_details( &self, workspace_id: &Uuid, database_id: &str, row_ids: &[&str], with_doc: bool, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{}/database/{}/row/detail", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&ListDatabaseRowDetailParam::new(row_ids, with_doc)) .send() .await?; process_response_data::>(resp).await } /// Example payload: /// { /// "Name": "some_data", # using column name /// "_pIkG": "some other data" # using field_id (can be obtained from [get_database_fields]) /// } /// Upon success, returns the row id for the newly created row. pub async fn add_database_item( &self, workspace_id: &Uuid, database_id: &str, cells_by_id: HashMap, row_doc_content: Option, ) -> Result { let url = format!( "{}/api/workspace/{}/database/{}/row", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&AddDatatabaseRow { cells: cells_by_id, document: row_doc_content, }) .send() .await?; process_response_data::(resp).await } /// Like [add_database_item], but use a [pre_hash] as identifier of the row /// Given the same `pre_hash` value will result in the same row /// Creates the row if now exists, else row will be modified pub async fn upsert_database_item( &self, workspace_id: &Uuid, database_id: &str, pre_hash: String, cells_by_id: HashMap, row_doc_content: Option, ) -> Result { let url = format!( "{}/api/workspace/{}/database/{}/row", self.base_url, workspace_id, database_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&UpsertDatatabaseRow { pre_hash, cells: cells_by_id, document: row_doc_content, }) .send() .await?; process_response_data::(resp).await } #[instrument(level = "debug", skip_all, err)] pub async fn post_realtime_msg( &self, device_id: &str, msg: client_websocket::Message, ) -> Result<(), AppResponseError> { let device_id = device_id.to_string(); let payload = blocking_brotli_compress(msg.into_data(), 6, self.config.compression_buffer_size).await?; let msg = HttpRealtimeMessage { device_id, payload }.encode_to_vec(); let body = Body::wrap_stream(stream::iter(vec![Ok::<_, reqwest::Error>(msg)])); let url = format!("{}/api/realtime/post/stream", self.base_url); let resp = self .http_client_with_auth_compress(Method::POST, &url) .await? .body(body) .send() .await?; process_response_error(resp).await } #[instrument(level = "debug", skip_all, err)] pub async fn create_collab_list( &self, workspace_id: &Uuid, params_list: Vec, ) -> Result<(), AppResponseError> { let url = self.batch_create_collab_url(workspace_id); let compression_tasks = params_list .into_par_iter() .filter_map(|params| { let data = CreateCollabData::from(params).to_bytes().ok()?; brotli_compress( data, self.config.compression_quality, self.config.compression_buffer_size, ) .ok() }) .collect::>(); let mut framed_data = Vec::new(); let mut size_count = 0; for compressed in compression_tasks { // The length of a u32 in bytes is 4. The server uses a u32 to read the size of each data frame, // hence the frame size header is always 4 bytes. It's crucial not to alter this size value, // as the server's logic for frame size reading is based on this fixed 4-byte length. // note: // the size of a u32 is a constant 4 bytes across all platforms that Rust supports. let size = compressed.len() as u32; framed_data.extend_from_slice(&size.to_be_bytes()); framed_data.extend_from_slice(&compressed); size_count += size; } event!( tracing::Level::INFO, "create batch collab with size: {}", size_count ); let body = Body::wrap_stream(stream::once(async { Ok::<_, AppError>(framed_data) })); let resp = self .http_client_with_auth_compress(Method::POST, &url) .await? .timeout(Duration::from_secs(60)) .body(body) .send() .await?; process_response_error(resp).await } #[instrument(level = "debug", skip_all)] pub async fn get_collab( &self, params: QueryCollabParams, ) -> Result { // 2 seconds, 4 seconds, 8 seconds let retry_strategy = ExponentialBackoff::from_millis(2).factor(1000).take(3); let action = GetCollabAction::new(self.clone(), params); RetryIf::spawn(retry_strategy, action, RetryGetCollabCondition).await } pub async fn publish_collabs( &self, workspace_id: &Uuid, items: Vec>, ) -> Result<(), AppResponseError> where Metadata: serde::Serialize + Send + 'static + Unpin, Data: AsRef<[u8]> + Send + 'static + Unpin, { let publish_collab_stream = PublishCollabItemStream::new(items); let url = format!("{}/api/workspace/{}/publish", self.base_url, workspace_id,); let resp = self .http_client_with_auth(Method::POST, &url) .await? .body(Body::wrap_stream(publish_collab_stream)) .send() .await?; process_response_error(resp).await } pub async fn check_if_row_document_collab_exists( &self, workspace_id: &Uuid, object_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{workspace_id}/collab/{object_id}/row-document-collab-exists", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; let info = process_response_data::(resp).await?; Ok(info.exists) } pub async fn get_collab_embed_info( &self, workspace_id: &Uuid, object_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{workspace_id}/collab/{object_id}/embed-info", self.base_url ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .header("Content-Type", "application/json") .send() .await?; process_response_data::(resp).await } pub async fn batch_get_collab_embed_info( &self, workspace_id: &Uuid, params: Vec, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{workspace_id}/collab/embed-info/list", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(¶ms) .send() .await?; process_response_data::(resp) .await .map(|data| data.0) } pub async fn force_generate_collab_embeddings( &self, workspace_id: &Uuid, object_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{workspace_id}/collab/{object_id}/generate-embedding", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .send() .await?; process_response_error(resp).await } pub async fn collab_full_sync( &self, workspace_id: &Uuid, object_id: &Uuid, collab_type: CollabType, doc_state: Vec, state_vector: Vec, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/v1/{workspace_id}/collab/{object_id}/full-sync", self.base_url ); // 3 is default level let doc_state = zstd::encode_all(Cursor::new(doc_state), 3) .map_err(|err| AppError::InvalidRequest(format!("Failed to compress text: {}", err)))?; let sv = zstd::encode_all(Cursor::new(state_vector), 3) .map_err(|err| AppError::InvalidRequest(format!("Failed to compress text: {}", err)))?; let params = CollabDocStateParams { object_id: object_id.to_string(), collab_type: collab_type.value(), compression: PayloadCompressionType::Zstd as i32, sv, doc_state, }; let mut encoded_payload = Vec::new(); params.encode(&mut encoded_payload).map_err(|err| { AppError::Internal(anyhow!("Failed to encode CollabDocStateParams: {}", err)) })?; let resp = self .http_client_with_auth(Method::POST, &url) .await? .body(Bytes::from(encoded_payload)) .send() .await?; if resp.status().is_success() { let body = resp.bytes().await?; let decompressed_body = zstd::decode_all(Cursor::new(body))?; Ok(decompressed_body) } else { process_response_data::>(resp).await } } } struct RetryGetCollabCondition; impl Condition for RetryGetCollabCondition { fn should_retry(&mut self, error: &AppResponseError) -> bool { !error.is_record_not_found() } } pub struct PublishCollabItemStream { items: Vec>, idx: usize, done: bool, } impl PublishCollabItemStream { pub fn new(publish_collab_items: Vec>) -> Self { PublishCollabItemStream { items: publish_collab_items, idx: 0, done: false, } } } impl Stream for PublishCollabItemStream where Metadata: Serialize + Send + 'static + Unpin, Data: AsRef<[u8]> + Send + 'static + Unpin, { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { let mut self_mut = self.as_mut(); if self_mut.idx >= self_mut.items.len() { if !self_mut.done { self_mut.done = true; return Poll::Ready(Some(Ok((0_u32).to_le_bytes().to_vec().into()))); } return Poll::Ready(None); } let item = &self_mut.items[self_mut.idx]; match serialize_metadata_data(&item.meta, item.data.as_ref()) { Err(e) => Poll::Ready(Some(Err(e))), Ok(chunk) => { self_mut.idx += 1; Poll::Ready(Some(Ok::(chunk))) }, } } } fn serialize_metadata_data(m: Metadata, d: &[u8]) -> Result where Metadata: Serialize, { let meta = serde_json::to_vec(&m)?; let mut chunk = Vec::with_capacity(8 + meta.len() + d.len()); chunk.extend_from_slice(&(meta.len() as u32).to_le_bytes()); // Encode metadata length chunk.extend_from_slice(&meta); chunk.extend_from_slice(&(d.len() as u32).to_le_bytes()); // Encode data length chunk.extend_from_slice(d); Ok(Bytes::from(chunk)) } pub(crate) struct GetCollabAction { client: Client, params: QueryCollabParams, } impl GetCollabAction { pub fn new(client: Client, params: QueryCollabParams) -> Self { Self { client, params } } } impl Action for GetCollabAction { type Future = Pin> + Send + Sync>>; type Item = CollabResponse; type Error = AppResponseError; fn run(&mut self) -> Self::Future { let client = self.client.clone(); let params = self.params.clone(); let collab_type = self.params.collab_type; Box::pin(async move { let url = format!( "{}/api/workspace/v1/{}/collab/{}", client.base_url, ¶ms.workspace_id, ¶ms.object_id ); let resp = client .http_client_with_auth(Method::GET, &url) .await? .query(&CollabTypeParam { collab_type }) .send() .await?; process_response_data::(resp).await }) } } ================================================ FILE: libs/client-api/src/http_file.rs ================================================ use crate::ws::{ConnectInfo, WSClientConnectURLProvider, WSClientHttpSender, WSError}; use crate::{process_response_data, process_response_error, Client}; use app_error::AppError; use async_trait::async_trait; use std::fs::metadata; use client_api_entity::{ CompleteUploadRequest, CreateUploadRequest, CreateUploadResponse, UploadPartResponse, }; use client_api_entity::{CreateImportTask, CreateImportTaskResponse}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use reqwest::{multipart, Body, Method}; use shared_entity::response::AppResponseError; use std::path::Path; use base64::engine::general_purpose::STANDARD; use base64::Engine; use shared_entity::dto::import_dto::UserImportTask; use tokio::fs::File; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio_util::codec::{BytesCodec, FramedRead}; use tracing::{error, trace}; use uuid::Uuid; impl Client { pub async fn create_upload( &self, workspace_id: &Uuid, req: CreateUploadRequest, ) -> Result { trace!("create_upload: {}", req); let url = format!( "{}/api/file_storage/{workspace_id}/create_upload", self.base_url ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&req) .send() .await?; process_response_data::(resp).await } /// Upload a part of a file. The part number should be 1-based. /// /// In Amazon S3, the minimum chunk size for multipart uploads is 5 MB,except for the last part, /// which can be smaller.(https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html) pub async fn upload_part( &self, workspace_id: &Uuid, parent_dir: &str, file_id: &str, upload_id: &str, part_number: i32, body: Vec, ) -> Result { if body.is_empty() { return Err(AppResponseError::from(AppError::InvalidRequest( "Empty body".to_string(), ))); } // Encode the parent directory to ensure it's URL-safe. let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string(); let url = format!( "{}/api/file_storage/{workspace_id}/upload_part/{parent_dir}/{file_id}/{upload_id}/{part_number}", self.base_url ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .body(body) .send() .await?; process_response_data::(resp).await } pub async fn complete_upload( &self, workspace_id: &Uuid, req: CompleteUploadRequest, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/file_storage/{}/complete_upload", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&req) .send() .await?; process_response_error(resp).await } /// Sends a POST request to import a file to the server. /// /// This function streams the contents of a file located at the provided `file_path` /// as part of a multipart form data request to the server's `/api/import` endpoint. /// /// ### HTTP Request Details: /// /// - **Method:** POST /// - **URL:** `{base_url}/api/import` /// - The `base_url` is dynamically provided and appended with `/api/import`. /// /// - **Headers:** /// - `X-Host`: The value of the `base_url` is sent as the host header. /// - `X-Content-Length`: The size of the file, in bytes, is provided from the file's metadata. /// /// - **Multipart Form:** /// - The file is sent as a multipart form part: /// - **Field Name:** The file name derived from the file path or a UUID if unavailable. /// - **File Content:** The file's content is streamed using `reqwest::Body::wrap_stream`. /// - **MIME Type:** Guessed from the file's extension using the `mime_guess` crate, /// defaulting to `application/octet-stream` if undetermined. /// /// ### Parameters: /// - `file_path`: The path to the file to be uploaded. /// - The file is opened asynchronously and its metadata (like size) is extracted. /// - The MIME type is automatically determined based on the file extension using `mime_guess`. /// pub async fn import_file(&self, file_path: &Path) -> Result<(), AppResponseError> { let md5_base64 = calculate_md5(file_path).await?; let file = File::open(&file_path).await?; let metadata = file.metadata().await?; let file_name = file_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let stream = FramedRead::new(file, BytesCodec::new()); let mime = mime_guess::from_path(file_path) .first_or_octet_stream() .to_string(); let file_part = multipart::Part::stream(reqwest::Body::wrap_stream(stream)) .file_name(file_name.clone()) .mime_str(&mime)?; let form = multipart::Form::new().part(file_name, file_part); let url = format!("{}/api/import", self.base_url); let mut builder = self .http_client_with_auth(Method::POST, &url) .await? .multipart(form); // set the host header builder = builder .header("X-Host", self.base_url.clone()) .header("X-Content-MD5", md5_base64) .header("X-Content-Length", metadata.len()); let resp = builder.send().await?; process_response_error(resp).await } /// Creates an import task for a file and returns the import task response. /// /// This function initiates an import task by sending a POST request to the /// `/api/import/create` endpoint. The request includes the `workspace_name` derived /// from the provided file's name (or a generated UUID if the file name cannot be determined). /// /// After creating the import task, you should use [Self::upload_import_file] to upload /// the actual file to the presigned URL obtained from the [CreateImportTaskResponse]. /// pub async fn create_import( &self, file_path: &Path, ) -> Result { let url = format!("{}/api/import/create", self.base_url); let file_name = file_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let content_length = tokio::fs::metadata(file_path).await?.len(); let params = CreateImportTask { workspace_name: file_name.clone(), content_length, }; let resp = self .http_client_with_auth(Method::POST, &url) .await? .header("X-Host", self.base_url.clone()) .json(¶ms) .send() .await?; process_response_data::(resp).await } /// Uploads a file to a specified presigned URL obtained from the import task response. /// /// This function uploads a file to the given presigned URL using an HTTP PUT request. /// The file's metadata is read to determine its size, and the upload stream is created /// and sent to the provided URL. It is recommended to call this function after successfully /// creating an import task using [Self::create_import]. /// pub async fn upload_import_file( &self, file_path: &Path, url: &str, ) -> Result<(), AppResponseError> { let file_metadata = metadata(file_path)?; let file_size = file_metadata.len(); // Open the file let file = File::open(file_path).await?; let file_stream = FramedRead::new(file, BytesCodec::new()); let stream_body = Body::wrap_stream(file_stream); trace!("start upload file to s3: {}", url); let client = reqwest::Client::new(); let upload_resp = client .put(url) .header("Content-Length", file_size) .header("Content-Type", "application/zip") .body(stream_body) .send() .await?; if !upload_resp.status().is_success() { error!("File upload failed: {:?}", upload_resp); return Err(AppError::S3ResponseError("Cannot upload file to S3".to_string()).into()); } Ok(()) } pub async fn get_import_list(&self) -> Result { let url = format!("{}/api/import", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } } #[async_trait] impl WSClientHttpSender for Client { async fn send_ws_msg( &self, device_id: &str, message: client_websocket::Message, ) -> Result<(), WSError> { self .post_realtime_msg(device_id, message) .await .map_err(|err| WSError::Http(err.to_string())) } } #[async_trait] impl WSClientConnectURLProvider for Client { fn connect_ws_url(&self) -> String { self.ws_addr.clone() } async fn connect_info(&self) -> Result { let conn_info = self .ws_connect_info(true) .await .map_err(|err| WSError::Http(err.to_string()))?; Ok(conn_info) } } /// Calculates the MD5 hash of a file and returns the base64-encoded MD5 digest. /// /// # Arguments /// * `file_path` - The path of the file for which the MD5 hash is to be calculated. /// /// # Returns /// A `Result` containing the base64-encoded MD5 hash on success, or an error if the file cannot be read. /// /// Asynchronously calculates the MD5 hash of a file using efficient buffer handling and returns it as a base64-encoded string. /// /// # Arguments /// * `file_path` - The path to the file to be hashed. /// /// # Returns /// Returns a `Result` containing the base64-encoded MD5 hash on success, or an error if the file cannot be read. pub async fn calculate_md5(file_path: &Path) -> Result { let file = File::open(file_path).await?; let mut reader = BufReader::with_capacity(1_000_000, file); let mut context = md5::Context::new(); loop { let part = reader.fill_buf().await?; if part.is_empty() { break; } context.consume(part); let part_len = part.len(); reader.consume(part_len); } let md5_hash = context.compute(); let md5_base64 = STANDARD.encode(md5_hash.as_ref()); Ok(md5_base64) } ================================================ FILE: libs/client-api/src/http_guest.rs ================================================ use client_api_entity::guest_dto::{ RevokeSharedViewAccessRequest, ShareViewWithGuestRequest, SharedViewDetails, SharedViewDetailsRequest, SharedViews, }; use reqwest::Method; use shared_entity::response::AppResponseError; use uuid::Uuid; use crate::{process_response_data, process_response_error, Client}; impl Client { pub async fn share_view_with_guest( &self, workspace_id: &Uuid, params: &ShareViewWithGuestRequest, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/sharing/workspace/{}/view", self.base_url, workspace_id, ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(params) .send() .await?; process_response_error(resp).await } pub async fn revoke_shared_view_access( &self, workspace_id: &Uuid, view_id: &Uuid, params: &RevokeSharedViewAccessRequest, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/sharing/workspace/{}/view/{}/revoke-access", self.base_url, workspace_id, view_id, ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(params) .send() .await?; process_response_error(resp).await } pub async fn get_shared_views( &self, workspace_id: &Uuid, ) -> Result { let url = format!( "{}/api/sharing/workspace/{}/view", self.base_url, workspace_id, ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data(resp).await } pub async fn get_shared_view_details( &self, workspace_id: &Uuid, view_id: &Uuid, ancestor_view_ids: &[Uuid], ) -> Result { let url = format!( "{}/api/sharing/workspace/{}/view/{}/access-details", self.base_url, workspace_id, view_id, ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&SharedViewDetailsRequest { ancestor_view_ids: ancestor_view_ids.to_vec(), }) .send() .await?; process_response_data(resp).await } } ================================================ FILE: libs/client-api/src/http_member.rs ================================================ use crate::{process_response_data, process_response_error, Client}; use client_api_entity::{ AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceMember, QueryWorkspaceMember, }; use reqwest::Method; use shared_entity::dto::workspace_dto::{ CreateWorkspaceMembers, WorkspaceMemberChangeset, WorkspaceMemberInvitation, WorkspaceMembers, }; use shared_entity::response::AppResponseError; use tracing::instrument; use uuid::Uuid; impl Client { #[instrument(level = "info", skip_all, err)] pub async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/leave", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&()) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_members( &self, workspace_id: &Uuid, ) -> Result, AppResponseError> { let url = format!("{}/api/workspace/{}/member", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn invite_workspace_members( &self, workspace_id: &Uuid, invitations: Vec, ) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/invite", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&invitations) .send() .await?; process_response_error(resp).await } pub async fn list_workspace_invitations( &self, status: Option, ) -> Result, AppResponseError> { let url = format!("{}/api/workspace/invite", self.base_url); let mut builder = self.http_client_with_auth(Method::GET, &url).await?; if let Some(status) = status { builder = builder.query(&[("status", status)]) } let resp = builder.send().await?; process_response_data::>(resp).await } pub async fn get_workspace_invitation( &self, invite_uuid: &str, ) -> Result { let url = format!("{}/api/workspace/invite/{}", self.base_url, invite_uuid); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn accept_workspace_invitation( &self, invitation_id: &str, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/accept-invite/{}", self.base_url, invitation_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&()) .send() .await?; process_response_error(resp).await } #[deprecated(note = "use invite_workspace_members instead")] #[instrument(level = "info", skip_all, err)] pub async fn add_workspace_members, W: AsRef>( &self, workspace_id: W, members: T, ) -> Result<(), AppResponseError> { let members = members.into(); let url = format!( "{}/api/workspace/{}/member", self.base_url, workspace_id.as_ref() ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&members) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn update_workspace_member( &self, workspace_id: &Uuid, changeset: WorkspaceMemberChangeset, ) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/member", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&changeset) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn remove_workspace_members( &self, workspace_id: &Uuid, member_emails: Vec, ) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/member", self.base_url, workspace_id); let payload = WorkspaceMembers::from(member_emails); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .json(&payload) .send() .await?; process_response_error(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_member( &self, params: QueryWorkspaceMember, ) -> Result { let url = format!( "{}/api/workspace/{}/member/user/{}", self.base_url, params.workspace_id, params.uid, ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_person.rs ================================================ use app_error::AppError; use client_api_entity::{ MentionablePerson, MentionablePersons, MentionablePersonsWithAccess, PageMentionUpdate, UserImageAssetSource, WorkspaceMemberProfile, }; use reqwest::{multipart, Method, StatusCode}; use shared_entity::response::AppResponseError; use tracing::instrument; use uuid::Uuid; use crate::{process_response_data, process_response_error, Client}; impl Client { #[instrument(level = "info", skip_all, err)] pub async fn list_workspace_mentionable_persons( &self, workspace_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/mentionable-person", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_mentionable_person( &self, workspace_id: &Uuid, person_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/mentionable-person/{}", self.base_url, workspace_id, person_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn update_workspace_member_profile( &self, workspace_id: &Uuid, updated_profile: &WorkspaceMemberProfile, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/update-member-profile", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(updated_profile) .send() .await?; process_response_error(resp).await } pub async fn list_page_mentionable_persons( &self, workspace_id: &Uuid, view_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/page-view/{}/mentionable-person-with-access", self.base_url, workspace_id, view_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn update_page_mention( &self, workspace_id: &Uuid, view_id: &Uuid, page_mention: &PageMentionUpdate, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/page-view/{}/page-mention", self.base_url, workspace_id, view_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(page_mention) .send() .await?; process_response_error(resp).await } pub async fn upload_user_image_asset( &self, local_file_path: &str, ) -> Result { let form = multipart::Form::new() .file("asset", local_file_path) .await?; let url = format!("{}/api/user/asset/image", self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .multipart(form) .send() .await?; process_response_data(resp).await } pub async fn get_user_image_asset( &self, person_id: &Uuid, file_id: &str, ) -> Result, AppResponseError> { let url = format!( "{}/api/user/asset/image/person/{}/file/{}", self.base_url, person_id, file_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; match resp.status() { StatusCode::OK => Ok(resp.bytes().await?.to_vec()), StatusCode::NOT_FOUND => Err(AppResponseError::from(AppError::RecordNotFound( url.to_owned(), ))), status => { let message = resp .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); Err(AppResponseError::from(AppError::Unhandled(format!( "status code: {}, message: {}", status, message )))) }, } } } ================================================ FILE: libs/client-api/src/http_publish.rs ================================================ use crate::{process_response_data, process_response_error, Client}; use bytes::Bytes; use client_api_entity::publish_dto::DuplicatePublishedPageResponse; use client_api_entity::workspace_dto::{PublishInfoView, PublishedView}; use client_api_entity::{workspace_dto::PublishedDuplicate, PublishInfo, UpdatePublishNamespace}; use client_api_entity::{ CreateGlobalCommentParams, CreateReactionParams, DeleteGlobalCommentParams, DeleteReactionParams, GetReactionQueryParams, GlobalComments, PatchPublishedCollab, PublishInfoMeta, Reactions, UpdateDefaultPublishView, }; use reqwest::Method; use shared_entity::response::AppResponseError; use tracing::instrument; use uuid::Uuid; // Publisher API impl Client { #[instrument(level = "debug", skip_all)] pub async fn list_published_views( &self, workspace_id: &Uuid, ) -> Result, AppResponseError> { let url = format!( "{}/api/workspace/{}/published-info", self.base_url, workspace_id, ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } /// Changes the namespace for the first non-original publish namespace /// or the original publish namespace if not exists. pub async fn set_workspace_publish_namespace( &self, workspace_id: &Uuid, new_namespace: String, ) -> Result<(), AppResponseError> { let old_namespace = self.get_workspace_publish_namespace(workspace_id).await?; let url = format!( "{}/api/workspace/{}/publish-namespace", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&UpdatePublishNamespace { old_namespace, new_namespace, }) .send() .await?; process_response_error(resp).await } pub async fn get_workspace_publish_namespace( &self, workspace_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/publish-namespace", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn patch_published_collabs( &self, workspace_id: &Uuid, patches: &[PatchPublishedCollab], ) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/publish", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::PATCH, &url) .await? .json(patches) .send() .await?; process_response_error(resp).await } pub async fn unpublish_collabs( &self, workspace_id: &Uuid, view_ids: &[Uuid], ) -> Result<(), AppResponseError> { let url = format!("{}/api/workspace/{}/publish", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .json(view_ids) .send() .await?; process_response_error(resp).await } pub async fn create_comment_on_published_view( &self, view_id: &Uuid, comment_content: &str, reply_comment_id: &Option, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/published-info/{}/comment", self.base_url, view_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&CreateGlobalCommentParams { content: comment_content.to_string(), reply_comment_id: *reply_comment_id, }) .send() .await?; process_response_error(resp).await } pub async fn delete_comment_on_published_view( &self, view_id: &Uuid, comment_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/published-info/{}/comment", self.base_url, view_id ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .json(&DeleteGlobalCommentParams { comment_id: *comment_id, }) .send() .await?; process_response_error(resp).await } pub async fn create_reaction_on_comment( &self, reaction_type: &str, view_id: &Uuid, comment_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/published-info/{}/reaction", self.base_url, view_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&CreateReactionParams { reaction_type: reaction_type.to_string(), comment_id: *comment_id, }) .send() .await?; process_response_error(resp).await } pub async fn delete_reaction_on_comment( &self, reaction_type: &str, view_id: &Uuid, comment_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/published-info/{}/reaction", self.base_url, view_id ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .json(&DeleteReactionParams { reaction_type: reaction_type.to_string(), comment_id: *comment_id, }) .send() .await?; process_response_error(resp).await } pub async fn set_default_publish_view( &self, workspace_id: &Uuid, view_id: Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/publish-default", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&UpdateDefaultPublishView { view_id }) .send() .await?; process_response_error(resp).await } pub async fn delete_default_publish_view( &self, workspace_id: &Uuid, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/{}/publish-default", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } pub async fn get_default_publish_view_info( &self, workspace_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/{}/publish-default", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } } // Optional login impl Client { pub async fn get_published_view_comments( &self, view_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/published-info/{}/comment", self.base_url, view_id ); let client = if let Ok(client) = self.http_client_with_auth(Method::GET, &url).await { client } else { self.http_client_without_auth(Method::GET, &url).await? }; let resp = client.send().await?; process_response_data::(resp).await } } // Guest API (no login required) impl Client { #[instrument(level = "debug", skip_all)] pub async fn get_published_collab_info( &self, view_id: &Uuid, ) -> Result { let url = format!( "{}/api/workspace/v1/published-info/{}", self.base_url, view_id ); let resp = self.cloud_client.get(&url).send().await?; process_response_data::(resp).await } #[instrument(level = "debug", skip_all)] pub async fn get_published_outline( &self, publish_namespace: &str, ) -> Result { let url = format!( "{}/api/workspace/published-outline/{}", self.base_url, publish_namespace, ); let resp = self .cloud_client .get(&url) .send() .await? .error_for_status()?; process_response_data::(resp).await } #[instrument(level = "debug", skip_all)] pub async fn get_default_published_collab( &self, publish_namespace: &str, ) -> Result, AppResponseError> where T: serde::de::DeserializeOwned + 'static, { let url = format!( "{}/api/workspace/published/{}", self.base_url, publish_namespace, ); let resp = self .cloud_client .get(&url) .send() .await? .error_for_status()?; process_response_data::>(resp).await } #[instrument(level = "debug", skip_all)] pub async fn get_published_collab( &self, publish_namespace: &str, publish_name: &str, ) -> Result where T: serde::de::DeserializeOwned + 'static, { tracing::debug!( "get_published_collab: {} {}", publish_namespace, publish_name ); let url = format!( "{}/api/workspace/v1/published/{}/{}", self.base_url, publish_namespace, publish_name ); let resp = self .cloud_client .get(&url) .send() .await? .error_for_status()?; process_response_data::(resp).await } #[instrument(level = "debug", skip_all)] pub async fn get_published_collab_blob( &self, publish_namespace: &str, publish_name: &str, ) -> Result { tracing::debug!( "get_published_collab_blob: {} {}", publish_namespace, publish_name ); let url = format!( "{}/api/workspace/published/{}/{}/blob", self.base_url, publish_namespace, publish_name ); let resp = self.cloud_client.get(&url).send().await?; let bytes = resp.error_for_status()?.bytes().await?; if let Ok(app_err) = serde_json::from_slice::(&bytes) { return Err(app_err); } Ok(bytes) } pub async fn duplicate_published_to_workspace( &self, workspace_id: Uuid, publish_duplicate: &PublishedDuplicate, ) -> Result { let url = format!( "{}/api/workspace/{}/published-duplicate", self.base_url, workspace_id ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(publish_duplicate) .send() .await?; process_response_data::(resp).await } pub async fn get_published_view_reactions( &self, view_id: &Uuid, comment_id: &Option, ) -> Result { let url = format!( "{}/api/workspace/published-info/{}/reaction", self.base_url, view_id ); let resp = self .cloud_client .get(url) .query(&GetReactionQueryParams { comment_id: *comment_id, }) .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_quick_note.rs ================================================ use client_api_entity::{ CreateQuickNoteParams, ListQuickNotesQueryParams, QuickNote, QuickNotes, UpdateQuickNoteParams, }; use reqwest::Method; use shared_entity::response::AppResponseError; use uuid::Uuid; use crate::{process_response_data, process_response_error, Client}; fn quick_note_resources_url(base_url: &str, workspace_id: Uuid) -> String { format!("{base_url}/api/workspace/{workspace_id}/quick-note") } fn quick_note_resource_url(base_url: &str, workspace_id: Uuid, quick_note_id: Uuid) -> String { let quick_note_resources_prefix = quick_note_resources_url(base_url, workspace_id); format!("{quick_note_resources_prefix}/{quick_note_id}") } // Quick Note API impl Client { pub async fn create_quick_note( &self, workspace_id: Uuid, data: Option, ) -> Result { let url = quick_note_resources_url(&self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&CreateQuickNoteParams { data }) .send() .await?; process_response_data::(resp).await } pub async fn list_quick_notes( &self, workspace_id: Uuid, search_term: Option, offset: Option, limit: Option, ) -> Result { let url = quick_note_resources_url(&self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? .query(&ListQuickNotesQueryParams { search_term, offset, limit, }) .send() .await?; process_response_data::(resp).await } pub async fn update_quick_note( &self, workspace_id: Uuid, quick_note_id: Uuid, data: serde_json::Value, ) -> Result<(), AppResponseError> { let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&UpdateQuickNoteParams { data }) .send() .await?; process_response_error(resp).await } pub async fn delete_quick_note( &self, workspace_id: Uuid, quick_note_id: Uuid, ) -> Result<(), AppResponseError> { let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } } ================================================ FILE: libs/client-api/src/http_search.rs ================================================ use app_error::ErrorCode; use reqwest::Method; use shared_entity::dto::search_dto::{ SearchDocumentResponseItem, SearchResult, SearchSummaryResult, SummarySearchResultRequest, }; use shared_entity::response::AppResponseError; use uuid::Uuid; use crate::{process_response_data, Client}; impl Client { /// If `score` is `None`, it will use the score from the server. High score means more relevant. /// score range is 0.0 to 1.0 pub async fn search_documents>>( &self, workspace_id: &Uuid, query: &str, limit: u32, preview_size: u32, score: T, ) -> Result, AppResponseError> { let mut raw_query = Vec::with_capacity(4); raw_query.push(("query", query.to_string())); raw_query.push(("limit", limit.to_string())); raw_query.push(("preview_size", preview_size.to_string())); if let Some(score_limit) = score.into() { raw_query.push(("score", score_limit.to_string())); } let query = serde_urlencoded::to_string(raw_query) .map_err(|err| AppResponseError::new(ErrorCode::InvalidRequest, err.to_string()))?; let url = format!("{}/api/search/{workspace_id}?{query}", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::>(resp).await } /// High score means more relevant pub async fn generate_search_summary( &self, workspace_id: &Uuid, query: &str, search_results: Vec, ) -> Result { let payload = SummarySearchResultRequest { query: query.to_string(), search_results, only_context: true, }; let url = format!("{}/api/search/{workspace_id}/summary", self.base_url); let resp = self .http_client_with_auth(Method::GET, &url) .await? .json(&payload) .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_settings.rs ================================================ use reqwest::Method; use tracing::{instrument, trace}; use client_api_entity::AFWorkspaceSettings; use shared_entity::response::AppResponseError; use crate::entity::AFWorkspaceSettingsChange; use crate::{process_response_data, Client}; impl Client { #[instrument(level = "info", skip_all, err)] pub async fn get_workspace_settings>( &self, workspace_id: T, ) -> Result { let url = format!( "{}/api/workspace/{}/settings", self.base_url, workspace_id.as_ref() ); let resp = self .http_client_with_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } #[instrument(level = "info", skip_all, err)] pub async fn update_workspace_settings>( &self, workspace_id: T, changes: &AFWorkspaceSettingsChange, ) -> Result { trace!("workspace settings: {:?}", changes); let url = format!( "{}/api/workspace/{}/settings", self.base_url, workspace_id.as_ref() ); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&changes) .send() .await?; process_response_data::(resp).await } } ================================================ FILE: libs/client-api/src/http_template.rs ================================================ use client_api_entity::{ AccountLink, CreateTemplateCategoryParams, CreateTemplateCreatorParams, CreateTemplateParams, GetTemplateCategoriesQueryParams, GetTemplateCreatorsQueryParams, GetTemplatesQueryParams, Template, TemplateCategories, TemplateCategory, TemplateCategoryType, TemplateCreator, TemplateCreators, TemplateWithPublishInfo, Templates, UpdateTemplateCategoryParams, UpdateTemplateCreatorParams, UpdateTemplateParams, }; use reqwest::Method; use shared_entity::response::AppResponseError; use uuid::Uuid; use crate::{process_response_data, process_response_error, Client}; fn template_api_prefix(base_url: &str) -> String { format!("{}/api/template-center", base_url) } fn category_resources_url(base_url: &str) -> String { format!("{}/category", template_api_prefix(base_url)) } fn category_resource_url(base_url: &str, category_id: Uuid) -> String { format!("{}/{}", category_resources_url(base_url), category_id) } fn template_creator_resources_url(base_url: &str) -> String { format!("{}/creator", template_api_prefix(base_url)) } fn template_creator_resource_url(base_url: &str, creator_id: Uuid) -> String { format!( "{}/{}", template_creator_resources_url(base_url), creator_id ) } fn template_resources_url(base_url: &str) -> String { format!("{}/template", template_api_prefix(base_url)) } fn template_resource_url(base_url: &str, view_id: Uuid) -> String { format!("{}/{}", template_resources_url(base_url), view_id) } impl Client { pub async fn create_template_category( &self, params: &CreateTemplateCategoryParams, ) -> Result { let url = category_resources_url(&self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(params) .send() .await?; process_response_data::(resp).await } pub async fn get_template_categories( &self, name_contains: Option<&str>, category_type: Option, ) -> Result { let url = category_resources_url(&self.base_url); let resp = self .http_client_without_auth(Method::GET, &url) .await? .query(&GetTemplateCategoriesQueryParams { name_contains: name_contains.map(|s| s.to_string()), category_type, }) .send() .await?; process_response_data::(resp).await } pub async fn get_template_category( &self, category_id: Uuid, ) -> Result { let url = category_resource_url(&self.base_url, category_id); let resp = self .http_client_without_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn delete_template_category(&self, category_id: Uuid) -> Result<(), AppResponseError> { let url = category_resource_url(&self.base_url, category_id); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } pub async fn update_template_category( &self, category_id: Uuid, params: &UpdateTemplateCategoryParams, ) -> Result { let url = category_resource_url(&self.base_url, category_id); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(params) .send() .await?; process_response_data::(resp).await } pub async fn create_template_creator( &self, name: &str, avatar_url: &str, account_links: Vec, ) -> Result { let url = template_creator_resources_url(&self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(&CreateTemplateCreatorParams { name: name.to_string(), avatar_url: avatar_url.to_string(), account_links, }) .send() .await?; process_response_data::(resp).await } pub async fn get_template_creators( &self, name_contains: Option<&str>, ) -> Result { let url = template_creator_resources_url(&self.base_url); let resp = self .http_client_without_auth(Method::GET, &url) .await? .query(&GetTemplateCreatorsQueryParams { name_contains: name_contains.map(|s| s.to_string()), }) .send() .await?; process_response_data::(resp).await } pub async fn get_template_creator( &self, creator_id: Uuid, ) -> Result { let url = template_creator_resource_url(&self.base_url, creator_id); let resp = self .http_client_without_auth(Method::GET, &url) .await? .send() .await?; process_response_data::(resp).await } pub async fn delete_template_creator(&self, creator_id: Uuid) -> Result<(), AppResponseError> { let url = template_creator_resource_url(&self.base_url, creator_id); let resp = self .http_client_with_auth(Method::DELETE, &url) .await? .send() .await?; process_response_error(resp).await } pub async fn update_template_creator( &self, creator_id: Uuid, name: &str, avatar_url: &str, account_links: Vec, ) -> Result { let url = template_creator_resource_url(&self.base_url, creator_id); let resp = self .http_client_with_auth(Method::PUT, &url) .await? .json(&UpdateTemplateCreatorParams { name: name.to_string(), avatar_url: avatar_url.to_string(), account_links, }) .send() .await?; process_response_data::(resp).await } pub async fn create_template( &self, params: &CreateTemplateParams, ) -> Result { let url = template_resources_url(&self.base_url); let resp = self .http_client_with_auth(Method::POST, &url) .await? .json(params) .send() .await?; process_response_data::