Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API

add profile init after login, add readme

+732 -12
+243
README.md
··· 1 + # Gleam Lustre Fullstack App 2 + 3 + A fullstack demo application built with Gleam and Lustre. Users can authenticate via OAuth, view and edit their profiles. 4 + 5 + ## Features 6 + 7 + - **OAuth 2.0 Authentication** - Secure authentication with PKCE flow for Bluesky/ATProto 8 + - **Profile Management** - View and edit user profiles with display name, description, avatar, location, and interests 9 + - **Avatar Upload** - Upload and preview profile images with decentralized blob storage 10 + - **Location Search** - Autocomplete location selection with H3 geohashing for geographic indexing 11 + - **Server-Side Rendering** - Fast initial page loads with prerendered profile data 12 + - **Client-Side Routing** - Smooth navigation with Modem routing library 13 + - **Session Management** - Secure cookie-based sessions with SQLite storage 14 + - **GraphQL Integration** - Direct integration with Slices network API for ATProto data 15 + 16 + ## Tech Stack 17 + 18 + ### Frontend (Client) 19 + - **Gleam** - Type-safe functional language compiled to JavaScript 20 + - **Lustre** - Elm-inspired web framework for reactive UIs 21 + - **Modem** - Client-side routing 22 + - **h3-js** - H3 geohashing for location indexing 23 + - **Tailwind CSS** - Utility-first CSS framework 24 + 25 + ### Backend (Server) 26 + - **Gleam** - Compiled to Erlang/BEAM 27 + - **Wisp** - Web framework for request handling 28 + - **Mist** - HTTP server runtime 29 + - **SQLight** - SQLite database driver 30 + - **Storail** - Session management 31 + - **Glow Auth** - OAuth utilities 32 + 33 + ### Shared 34 + - Monorepo structure with shared types and utilities between client and server 35 + 36 + ## Prerequisites 37 + 38 + - [Gleam](https://gleam.run/) (latest version) 39 + - [Erlang/OTP](https://www.erlang.org/) (for server) 40 + - [Node.js](https://nodejs.org/) and npm (for client dependencies) 41 + 42 + ## Project Structure 43 + 44 + ``` 45 + lustre-fullstack/ 46 + ├── client/ # Lustre frontend (SPA) 47 + │ ├── src/ 48 + │ │ ├── client.gleam # Main entry point 49 + │ │ ├── pages/ # Page components 50 + │ │ └── ui/ # Reusable UI components 51 + │ ├── gleam.toml 52 + │ └── package.json 53 + ├── server/ # Wisp backend 54 + │ ├── src/ 55 + │ │ ├── server.gleam # Main server & routing 56 + │ │ ├── api/ # API handlers 57 + │ │ └── oauth/ # OAuth & session logic 58 + │ ├── gleam.toml 59 + │ ├── .env.example 60 + │ └── priv/static/ # Built client output 61 + └── shared/ # Shared code between client & server 62 + └── src/shared/ 63 + └── profile.gleam # Profile types & codecs 64 + ``` 65 + 66 + ## Setup 67 + 68 + ### 1. Clone the Repository 69 + 70 + ```bash 71 + git clone <repository-url> 72 + cd conf-demo 73 + ``` 74 + 75 + ### 2. Set Up Server 76 + 77 + ```bash 78 + cd server 79 + 80 + # Install Gleam dependencies 81 + gleam deps download 82 + 83 + # Copy environment template 84 + cp .env.example .env 85 + 86 + # Edit .env with your configuration 87 + # Generate SECRET_KEY_BASE with: openssl rand -base64 48 88 + ``` 89 + 90 + ### 3. Set Up Client 91 + 92 + ```bash 93 + cd client 94 + 95 + # Install Gleam dependencies 96 + gleam deps download 97 + 98 + # Install npm dependencies (h3-js) 99 + npm install 100 + ``` 101 + 102 + ### 4. Set Up Shared Package 103 + 104 + ```bash 105 + cd shared 106 + gleam deps download 107 + ``` 108 + 109 + ## Configuration 110 + 111 + Edit `server/.env` with your OAuth and application settings: 112 + 113 + ```bash 114 + # Secret Key Base (generate with: openssl rand -base64 48) 115 + SECRET_KEY_BASE=your_random_64_character_string 116 + 117 + # OAuth Configuration 118 + OAUTH_CLIENT_ID=your_oauth_client_id 119 + OAUTH_CLIENT_SECRET=your_oauth_client_secret 120 + OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback 121 + OAUTH_AUTH_URL=https://auth.slices.network 122 + ``` 123 + 124 + ## Development 125 + 126 + ### Build Client 127 + 128 + The client builds directly into `server/priv/static` for serving: 129 + 130 + ```bash 131 + cd client 132 + gleam run 133 + ``` 134 + 135 + This compiles the Lustre app with minification and outputs to `../server/priv/static`. 136 + 137 + ### Run Server 138 + 139 + ```bash 140 + cd server 141 + gleam run 142 + ``` 143 + 144 + The server starts on `http://localhost:3000` and serves the built client from `priv/static`. 145 + 146 + ### Development Workflow 147 + 148 + 1. Make changes to client code in `client/src/` 149 + 2. Rebuild client: `cd client && gleam run` 150 + 3. Server automatically serves updated static files 151 + 4. For server changes, restart the server 152 + 153 + Alternatively, run both in separate terminals with auto-rebuild on file changes using your preferred file watcher. 154 + 155 + ## API Endpoints 156 + 157 + ### Public Routes 158 + - `GET /` - Home page 159 + - `GET /login` - Login page 160 + - `GET /profile/:handle` - View profile (with SSR) 161 + - `POST /oauth/authorize` - Initiate OAuth flow 162 + - `GET /oauth/callback` - OAuth callback 163 + 164 + ### Protected Routes (Require Authentication) 165 + - `GET /profile/:handle/edit` - Edit profile page 166 + - `POST /api/profile/:handle/update` - Update profile data 167 + - `POST /logout` - Logout 168 + 169 + ### API Routes 170 + - `GET /api/user/current` - Get current authenticated user 171 + - `GET /api/profile/:handle` - Fetch profile data (JSON) 172 + 173 + ## Database 174 + 175 + The application uses SQLite for session storage: 176 + - Database file: `server/sessions.db` (auto-created on first run) 177 + - Schema managed by `server/src/oauth/session.gleam` 178 + 179 + ## Features in Detail 180 + 181 + ### OAuth with PKCE 182 + Implements OAuth 2.0 with Proof Key for Code Exchange (PKCE) for secure authentication without storing client secrets in the browser. 183 + 184 + ### H3 Geohashing 185 + Location data is indexed using Uber's H3 geospatial indexing system for efficient location queries and autocomplete. 186 + 187 + ### Server-Side Rendering 188 + Profile pages include prerendered data in the initial HTML response, embedded as JSON in a script tag for instant hydration. 189 + 190 + ### GraphQL Integration 191 + Direct queries and mutations to the Slices network API for ATProto profile data with access token support. 192 + 193 + ## Building for Production 194 + 195 + ### Client 196 + ```bash 197 + cd client 198 + gleam run # Outputs minified bundle to ../server/priv/static 199 + ``` 200 + 201 + ### Server 202 + ```bash 203 + cd server 204 + gleam build 205 + gleam run 206 + ``` 207 + 208 + The server serves the built client from `priv/static` at runtime. 209 + 210 + ## Testing 211 + 212 + ```bash 213 + # Test client 214 + cd client 215 + gleam test 216 + 217 + # Test server 218 + cd server 219 + gleam test 220 + 221 + # Test shared 222 + cd shared 223 + gleam test 224 + ``` 225 + 226 + ## Contributing 227 + 228 + 1. Fork the repository 229 + 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 230 + 3. Commit your changes (`git commit -m 'Add amazing feature'`) 231 + 4. Push to the branch (`git push origin feature/amazing-feature`) 232 + 5. Open a Pull Request 233 + 234 + ## License 235 + 236 + Apache License, Version 2.0 237 + 238 + ## Acknowledgments 239 + 240 + - Built with [Gleam](https://gleam.run/) 241 + - Frontend powered by [Lustre](https://github.com/lustre-labs/lustre) 242 + - Backend powered by [Wisp](https://github.com/gleam-wisp/wisp) and [Mist](https://github.com/rawhat/mist) 243 + - Location indexing with [H3](https://h3geo.org/)
+2 -2
server/.env.example
··· 7 7 # These values will be used if environment variables are not set 8 8 9 9 # OAuth Client ID (used in authorization request) 10 - OAUTH_CLIENT_ID=43d2e6e5-60fe-491c-b93e-536ac99b71f1 10 + OAUTH_CLIENT_ID=client-id 11 11 12 12 # OAuth Client Secret (used in token exchange) 13 - OAUTH_CLIENT_SECRET=0elWjAl695lupMGMZ0IOo9xEy11dNiY96L08b6z_xZw 13 + OAUTH_CLIENT_SECRET=client-secret 14 14 15 15 # OAuth Redirect URI (where the OAuth provider redirects after authorization) 16 16 OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback
+1
server/gleam.toml
··· 28 28 sqlight = ">= 1.0.2 and < 2.0.0" 29 29 envoy = ">= 1.0.2 and < 2.0.0" 30 30 dotenv_gleam = ">= 2.0.1 and < 3.0.0" 31 + birl = ">= 1.0.0 and < 2.0.0" 31 32 32 33 [erlang] 33 34 extra_applications = ["inets", "ssl"]
+4
server/manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" }, 5 6 { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 6 7 { name = "dotenv_gleam", version = "2.0.1", build_tools = ["gleam"], requirements = ["envoy", "gleam_erlang", "gleam_stdlib", "simplifile"], otp_app = "dotenv_gleam", source = "hex", outer_checksum = "47391525F97AF2086B34A4F2E81C1A1102863ACA983540CD87A8D295B1636445" }, 7 8 { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, ··· 14 15 { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 15 16 { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 16 17 { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 18 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 17 19 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 18 20 { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 19 21 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, ··· 28 30 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 29 31 { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 30 32 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 33 + { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 31 34 { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, 32 35 { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 33 36 { name = "sqlight", version = "1.0.2", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "74327861946FD2DA2313F80FD0130DBB38A70F262869C783C58F8600C8675E7D" }, ··· 37 40 ] 38 41 39 42 [requirements] 43 + birl = { version = ">= 1.0.0 and < 2.0.0" } 40 44 dotenv_gleam = { version = ">= 2.0.1 and < 3.0.0" } 41 45 envoy = { version = ">= 1.0.2 and < 2.0.0" } 42 46 gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
+351
server/src/api/graphql.gleam
··· 598 598 ) 599 599 } 600 600 } 601 + 602 + // PROFILE INITIALIZATION HELPERS ---------------------------------------------- 603 + 604 + pub type BlueskyProfile { 605 + BlueskyProfile( 606 + display_name: Option(String), 607 + description: Option(String), 608 + avatar: Option(BlueskyAvatar), 609 + ) 610 + } 611 + 612 + pub type BlueskyAvatar { 613 + BlueskyAvatar(ref: String, mime_type: String, size: Int) 614 + } 615 + 616 + /// Check if a profile already exists for the given DID 617 + pub fn check_profile_exists(config: Config, did: String) -> Result(Bool, String) { 618 + let query = 619 + " 620 + query CheckProfile($did: String!) { 621 + orgAtmosphereconfProfiles(where: { did: { eq: $did } }, first: 1) { 622 + edges { 623 + node { 624 + id 625 + } 626 + } 627 + } 628 + } 629 + " 630 + 631 + let variables = json.object([#("did", json.string(did))]) 632 + 633 + let body_json = 634 + json.object([ 635 + #("query", json.string(query)), 636 + #("variables", variables), 637 + ]) 638 + 639 + use req <- result.try( 640 + request.to(config.api_url) 641 + |> result.map_error(fn(_) { "Failed to create request" }), 642 + ) 643 + 644 + let req = 645 + request.set_method(req, http.Post) 646 + |> request.set_header("content-type", "application/json") 647 + |> request.set_header("X-Slice-Uri", config.slice_uri) 648 + |> request.set_header("Authorization", "Bearer " <> config.access_token) 649 + |> request.set_body(json.to_string(body_json)) 650 + 651 + use resp <- result.try( 652 + httpc.send(req) 653 + |> result.map_error(fn(_) { "HTTP request failed" }), 654 + ) 655 + 656 + case resp.status { 657 + 200 -> { 658 + use data <- result.try( 659 + json.parse(resp.body, decode.dynamic) 660 + |> result.map_error(fn(_) { "Failed to parse JSON response" }), 661 + ) 662 + 663 + let edges_decoder = 664 + decode.at( 665 + ["data", "orgAtmosphereconfProfiles", "edges"], 666 + decode.list(decode.dynamic), 667 + ) 668 + 669 + use edges <- result.try( 670 + decode.run(data, edges_decoder) 671 + |> result.map_error(fn(_) { "Failed to extract edges" }), 672 + ) 673 + 674 + case edges { 675 + [] -> Ok(False) 676 + _ -> Ok(True) 677 + } 678 + } 679 + _ -> 680 + Error( 681 + "API returned status " 682 + <> string.inspect(resp.status) 683 + <> " with body: " 684 + <> resp.body, 685 + ) 686 + } 687 + } 688 + 689 + /// Sync user collections (Bluesky data) 690 + pub fn sync_user_collections(config: Config, did: String) -> Result(Nil, String) { 691 + let mutation = 692 + " 693 + mutation SyncUserCollections($did: String!) { 694 + syncUserCollections(did: $did) { 695 + success 696 + message 697 + } 698 + } 699 + " 700 + 701 + let variables = json.object([#("did", json.string(did))]) 702 + 703 + let body_json = 704 + json.object([ 705 + #("query", json.string(mutation)), 706 + #("variables", variables), 707 + ]) 708 + 709 + use req <- result.try( 710 + request.to(config.api_url) 711 + |> result.map_error(fn(_) { "Failed to create request" }), 712 + ) 713 + 714 + let req = 715 + request.set_method(req, http.Post) 716 + |> request.set_header("content-type", "application/json") 717 + |> request.set_header("X-Slice-Uri", config.slice_uri) 718 + |> request.set_header("Authorization", "Bearer " <> config.access_token) 719 + |> request.set_body(json.to_string(body_json)) 720 + 721 + use resp <- result.try( 722 + httpc.send(req) 723 + |> result.map_error(fn(_) { "HTTP request failed" }), 724 + ) 725 + 726 + case resp.status { 727 + 200 -> Ok(Nil) 728 + _ -> 729 + Error( 730 + "Sync failed with status " 731 + <> string.inspect(resp.status) 732 + <> " with body: " 733 + <> resp.body, 734 + ) 735 + } 736 + } 737 + 738 + /// Fetch Bluesky profile data 739 + pub fn get_bluesky_profile( 740 + config: Config, 741 + did: String, 742 + ) -> Result(Option(BlueskyProfile), String) { 743 + let query = 744 + " 745 + query GetBskyProfile($did: String!) { 746 + appBskyActorProfiles(where: { did: { eq: $did } }, first: 1) { 747 + edges { 748 + node { 749 + displayName 750 + description 751 + avatar { 752 + ref 753 + mimeType 754 + size 755 + } 756 + } 757 + } 758 + } 759 + } 760 + " 761 + 762 + let variables = json.object([#("did", json.string(did))]) 763 + 764 + let body_json = 765 + json.object([ 766 + #("query", json.string(query)), 767 + #("variables", variables), 768 + ]) 769 + 770 + use req <- result.try( 771 + request.to(config.api_url) 772 + |> result.map_error(fn(_) { "Failed to create request" }), 773 + ) 774 + 775 + let req = 776 + request.set_method(req, http.Post) 777 + |> request.set_header("content-type", "application/json") 778 + |> request.set_header("X-Slice-Uri", config.slice_uri) 779 + |> request.set_header("Authorization", "Bearer " <> config.access_token) 780 + |> request.set_body(json.to_string(body_json)) 781 + 782 + use resp <- result.try( 783 + httpc.send(req) 784 + |> result.map_error(fn(_) { "HTTP request failed" }), 785 + ) 786 + 787 + case resp.status { 788 + 200 -> { 789 + use data <- result.try( 790 + json.parse(resp.body, decode.dynamic) 791 + |> result.map_error(fn(_) { "Failed to parse JSON response" }), 792 + ) 793 + 794 + let edges_decoder = 795 + decode.at( 796 + ["data", "appBskyActorProfiles", "edges"], 797 + decode.list(decode.dynamic), 798 + ) 799 + 800 + use edges <- result.try( 801 + decode.run(data, edges_decoder) 802 + |> result.map_error(fn(_) { "Failed to extract edges" }), 803 + ) 804 + 805 + case edges { 806 + [] -> Ok(None) 807 + [first_edge, ..] -> { 808 + let display_name = case 809 + decode.run( 810 + first_edge, 811 + decode.at(["node", "displayName"], decode.optional(decode.string)), 812 + ) 813 + { 814 + Ok(val) -> val 815 + Error(_) -> None 816 + } 817 + 818 + let description = case 819 + decode.run( 820 + first_edge, 821 + decode.at(["node", "description"], decode.optional(decode.string)), 822 + ) 823 + { 824 + Ok(val) -> val 825 + Error(_) -> None 826 + } 827 + 828 + let avatar = case 829 + decode.run( 830 + first_edge, 831 + decode.at( 832 + ["node", "avatar"], 833 + decode.optional({ 834 + use ref <- decode.field("ref", decode.string) 835 + use mime_type <- decode.field("mimeType", decode.string) 836 + use size <- decode.field("size", decode.int) 837 + decode.success(BlueskyAvatar( 838 + ref: ref, 839 + mime_type: mime_type, 840 + size: size, 841 + )) 842 + }), 843 + ), 844 + ) 845 + { 846 + Ok(val) -> val 847 + Error(_) -> None 848 + } 849 + 850 + Ok(Some(BlueskyProfile( 851 + display_name: display_name, 852 + description: description, 853 + avatar: avatar, 854 + ))) 855 + } 856 + } 857 + } 858 + _ -> 859 + Error( 860 + "API returned status " 861 + <> string.inspect(resp.status) 862 + <> " with body: " 863 + <> resp.body, 864 + ) 865 + } 866 + } 867 + 868 + pub type ProfileInput { 869 + ProfileInput( 870 + display_name: String, 871 + description: Option(String), 872 + avatar: Option(json.Json), 873 + created_at: String, 874 + ) 875 + } 876 + 877 + /// Create a new profile 878 + pub fn create_profile( 879 + config: Config, 880 + input: ProfileInput, 881 + ) -> Result(Nil, String) { 882 + let mutation = 883 + " 884 + mutation CreateProfile( 885 + $input: OrgAtmosphereconfProfileInput! 886 + $rkey: String 887 + ) { 888 + createOrgAtmosphereconfProfile(input: $input, rkey: $rkey) { 889 + id 890 + } 891 + } 892 + " 893 + 894 + // Build input object 895 + let input_fields = [#("displayName", json.string(input.display_name))] 896 + 897 + let input_fields = case input.description { 898 + Some(val) -> [#("description", json.string(val)), ..input_fields] 899 + None -> input_fields 900 + } 901 + 902 + let input_fields = case input.avatar { 903 + Some(val) -> [#("avatar", val), ..input_fields] 904 + None -> input_fields 905 + } 906 + 907 + let input_fields = [ 908 + #("createdAt", json.string(input.created_at)), 909 + ..input_fields 910 + ] 911 + 912 + let variables = 913 + json.object([ 914 + #("rkey", json.string("self")), 915 + #("input", json.object(input_fields)), 916 + ]) 917 + 918 + let body_json = 919 + json.object([ 920 + #("query", json.string(mutation)), 921 + #("variables", variables), 922 + ]) 923 + 924 + use req <- result.try( 925 + request.to(config.api_url) 926 + |> result.map_error(fn(_) { "Failed to create request" }), 927 + ) 928 + 929 + let req = 930 + request.set_method(req, http.Post) 931 + |> request.set_header("content-type", "application/json") 932 + |> request.set_header("X-Slice-Uri", config.slice_uri) 933 + |> request.set_header("Authorization", "Bearer " <> config.access_token) 934 + |> request.set_body(json.to_string(body_json)) 935 + 936 + use resp <- result.try( 937 + httpc.send(req) 938 + |> result.map_error(fn(_) { "HTTP request failed" }), 939 + ) 940 + 941 + case resp.status { 942 + 200 -> Ok(Nil) 943 + _ -> 944 + Error( 945 + "Create profile failed with status " 946 + <> string.inspect(resp.status) 947 + <> " with body: " 948 + <> resp.body, 949 + ) 950 + } 951 + }
+104
server/src/api/profile_init.gleam
··· 1 + import api/graphql 2 + import birl 3 + import gleam/io 4 + import gleam/json 5 + import gleam/option.{None, Some} 6 + 7 + /// Initialize user profile by: 8 + /// 1. Checking if profile already exists 9 + /// 2. Syncing user collections (Bluesky data) 10 + /// 3. Fetching Bluesky profile 11 + /// 4. Creating org.atmosphereconf.profile with Bluesky data 12 + pub fn initialize_user_profile( 13 + config: graphql.Config, 14 + user_did: String, 15 + user_handle: String, 16 + ) -> Result(Nil, String) { 17 + // 1. Check if profile already exists 18 + case graphql.check_profile_exists(config, user_did) { 19 + Ok(True) -> { 20 + // User already has a profile, nothing to do 21 + io.println("Profile already exists for " <> user_did) 22 + Ok(Nil) 23 + } 24 + Ok(False) -> { 25 + io.println("Initializing profile for " <> user_did) 26 + 27 + // 2. Sync user collections (to get Bluesky data) 28 + let sync_result = graphql.sync_user_collections(config, user_did) 29 + case sync_result { 30 + Error(err) -> { 31 + io.println("Warning: Failed to sync collections: " <> err) 32 + // Continue anyway, we can still create a basic profile 33 + Nil 34 + } 35 + Ok(_) -> { 36 + io.println("Successfully synced user collections") 37 + Nil 38 + } 39 + } 40 + 41 + // 3. Fetch Bluesky profile data 42 + let bsky_profile_result = graphql.get_bluesky_profile(config, user_did) 43 + 44 + // 4. Create org.atmosphereconf.profile with Bluesky data 45 + let display_name = case bsky_profile_result { 46 + Ok(Some(profile)) -> 47 + case profile.display_name { 48 + Some(name) -> name 49 + None -> user_handle 50 + } 51 + _ -> user_handle 52 + } 53 + 54 + let description = case bsky_profile_result { 55 + Ok(Some(profile)) -> profile.description 56 + _ -> None 57 + } 58 + 59 + let avatar = case bsky_profile_result { 60 + Ok(Some(profile)) -> 61 + case profile.avatar { 62 + Some(avatar_blob) -> 63 + Some(json.object([ 64 + #("$type", json.string("blob")), 65 + #( 66 + "ref", 67 + json.object([#("$link", json.string(avatar_blob.ref))]), 68 + ), 69 + #("mimeType", json.string(avatar_blob.mime_type)), 70 + #("size", json.int(avatar_blob.size)), 71 + ])) 72 + None -> None 73 + } 74 + _ -> None 75 + } 76 + 77 + let now = birl.now() 78 + let created_at = birl.to_iso8601(now) 79 + 80 + let profile_input = 81 + graphql.ProfileInput( 82 + display_name: display_name, 83 + description: description, 84 + avatar: avatar, 85 + created_at: created_at, 86 + ) 87 + 88 + case graphql.create_profile(config, profile_input) { 89 + Ok(_) -> { 90 + io.println("Successfully initialized profile for " <> user_did) 91 + Ok(Nil) 92 + } 93 + Error(err) -> { 94 + io.println("Failed to create profile: " <> err) 95 + Error(err) 96 + } 97 + } 98 + } 99 + Error(err) -> { 100 + io.println("Failed to check if profile exists: " <> err) 101 + Error(err) 102 + } 103 + } 104 + }
+27 -10
server/src/server.gleam
··· 1 1 import api/graphql 2 + import api/profile_init 2 3 import dotenv_gleam 3 4 import envoy 4 5 import gleam/bit_array ··· 40 41 fn load_oauth_config() -> OAuthConfig { 41 42 OAuthConfig( 42 43 client_id: envoy.get("OAUTH_CLIENT_ID") 43 - |> result.unwrap("43d2e6e5-60fe-491c-b93e-536ac99b71f1"), 44 + |> result.unwrap(""), 44 45 client_secret: envoy.get("OAUTH_CLIENT_SECRET") 45 - |> result.unwrap("0elWjAl695lupMGMZ0IOo9xEy11dNiY96L08b6z_xZw"), 46 + |> result.unwrap(""), 46 47 redirect_uri: envoy.get("OAUTH_REDIRECT_URI") 47 48 |> result.unwrap("http://localhost:3000/oauth/callback"), 48 49 auth_url: envoy.get("OAUTH_AUTH_URL") 49 - |> result.unwrap("http://localhost:2583"), 50 + |> result.unwrap("http://localhost:3001"), 50 51 ) 51 52 } 52 53 ··· 483 484 case current_profile.avatar_blob { 484 485 Some(blob) -> { 485 486 // Convert AvatarBlob to JSON for the mutation 486 - Some(json.object([ 487 - #("$type", json.string("blob")), 488 - #("ref", json.object([#("$link", json.string(blob.ref))])), 489 - #("mimeType", json.string(blob.mime_type)), 490 - #("size", json.int(blob.size)), 491 - ])) 487 + Some( 488 + json.object([ 489 + #("$type", json.string("blob")), 490 + #("ref", json.object([#("$link", json.string(blob.ref))])), 491 + #("mimeType", json.string(blob.mime_type)), 492 + #("size", json.int(blob.size)), 493 + ]), 494 + ) 492 495 } 493 496 None -> None 494 497 } ··· 667 670 " Handle: " <> option.unwrap(user_info.handle, "(none)"), 668 671 ) 669 672 673 + // Initialize user profile (silent failure) 674 + let graphql_config = 675 + graphql.Config( 676 + api_url: "https://api.slices.network/graphql", 677 + slice_uri: "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3m3gc7lhwzx2z", 678 + access_token: token_response.access_token, 679 + ) 680 + 681 + let _ = 682 + profile_init.initialize_user_profile( 683 + graphql_config, 684 + user_info.did, 685 + option.unwrap(user_info.handle, ""), 686 + ) 687 + 670 688 case 671 689 session.create_session( 672 690 db, ··· 677 695 ) 678 696 { 679 697 Ok(session_id) -> { 680 - wisp.log_info("OAuth: Session created: " <> session_id) 681 698 wisp.redirect("/") 682 699 |> session.set_session_cookie(req, session_id) 683 700 }