├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.md ├── README.md ├── examples ├── delete.rs ├── get.rs ├── post.rs ├── share.rs └── uh-oh.png ├── samples ├── example.project.posts.json └── with-ask.project.posts.json └── src ├── ask.rs ├── attachment.rs ├── client.rs ├── error.rs ├── lib.rs ├── post.rs └── session.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /Cargo.lock 3 | /target 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 -- 2023-07-31 2 | 3 | - eggbug-rs is now "lightly maintained": pull requests will generally be merged without testing, and new releases will generally be "breaking" (e.g. 0.2.x -> 0.3.x) unless I am positively certain a change does not break semver 4 | - Add ability to fetch posts by project and page (@NoraCodes, [#2](https://github.com/iliana/eggbug-rs/pull/2)) 5 | - Update attachment start API (@jkap, [#3](https://github.com/iliana/eggbug-rs/pull/3)) 6 | - Add read-only support for asks (@NoraCodes, [#4](https://github.com/iliana/eggbug-rs/pull/4)) 7 | - Audio attachments (@xenofem, [#6](https://github.com/iliana/eggbug-rs/pull/6)) 8 | 9 | ## 0.1.3 -- 2022-11-02 10 | 11 | - Fixed decoding the password hashing salt to match the official client ([#1](https://github.com/iliana/eggbug-rs/issues/1)). This problem affects roughly half of accounts on cohost, assuming password salts are randomly distributed. 12 | 13 | ## 0.1.2 -- 2022-08-01 14 | 15 | - Fixed pending attachment blocks to use an all-zeroes UUID instead of an empty string ([staff announcement of change](https://cohost.org/jkap/post/71976-potentially-breaking)). 16 | 17 | ## 0.1.1 -- 2022-07-31 18 | 19 | - Added support for alt text for attachments. 20 | 21 | ## 0.1.0 -- 2022-07-31 22 | 23 | - Initial release, with support for creating, sharing, editing, and deleting posts, as well as uploading attachments. 24 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "eggbug" 3 | version = "0.2.0" 4 | edition = "2021" 5 | description = "Bot library for cohost.org" 6 | repository = "https://github.com/iliana/eggbug-rs" 7 | license-file = "LICENSE.md" 8 | 9 | [dependencies] 10 | base64 = "0.13.0" 11 | bytes = "1.1.0" 12 | chrono = { version = "0.4.22", default-features = false, features = ["std", "serde"] } 13 | derive_more = { version = "0.99.17", default-features = false, features = ["display", "from", "from_str", "into"] } 14 | futures = { version = "0.3.21", default-features = false, features = ["alloc"] } 15 | hmac = "0.12.1" 16 | imagesize = { version = "0.11.0", optional = true } 17 | pbkdf2 = { version = "0.11.0", default-features = false } 18 | reqwest = { version = "0.11.11", default-features = false, features = ["cookies", "json", "multipart", "stream"] } 19 | serde = { version = "1.0.138", features = ["derive"] } 20 | serde_json = "1.0.82" 21 | sha2 = "0.10.2" 22 | thiserror = "1.0.31" 23 | tokio = { version = "1.19.2", default-features = false, optional = true } 24 | tokio-util = { version = "0.7.3", default-features = false, optional = true } 25 | tracing = "0.1.35" 26 | uuid = { version = "1.1.2", features = ["serde"] } 27 | 28 | [dev-dependencies] 29 | anyhow = "1.0.58" 30 | dotenv = "0.15.0" 31 | tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] } 32 | tracing-subscriber = { version = "0.3.14", features = ["env-filter"] } 33 | 34 | [features] 35 | default = ["default-tls", "fs"] 36 | default-tls = ["reqwest/default-tls"] 37 | fs = ["tokio/fs", "tokio-util/codec"] 38 | imagesize = ["dep:imagesize", "fs"] 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) 2 | 3 | Copyright © 2022 iliana etaoin 4 | 5 | This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. 6 | 7 | Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: 8 | 9 | 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 10 | 11 | 2. The User is one of the following: 12 | a. An individual person, laboring for themselves 13 | b. A non-profit organization 14 | c. An educational institution 15 | d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 16 | 17 | 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 18 | 19 | 4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eggbug 2 | 3 | eggbug-rs is a bot library for [cohost.org](https://cohost.org/rc/welcome), providing an 4 | interface to create, read, edit, and delete posts. 5 | 6 | ```rust 7 | use eggbug::{Post, Session}; 8 | 9 | // Log in 10 | let session = Session::login("eggbug@website.invalid", "hunter2").await?; 11 | 12 | // Describe a post 13 | let mut post = Post { 14 | headline: "hello from eggbug-rs!".into(), 15 | markdown: "wow it's like a website in here".into(), 16 | ..Default::default() 17 | }; 18 | 19 | // Create the post on the eggbug page 20 | let id = session.create_post("eggbug", &mut post).await?; 21 | 22 | // Oh wait we want to make that a link 23 | post.markdown = "wow it's [like a website in here](https://cohost.org/hthrflwrs/post/25147-empty)".into(); 24 | session.edit_post("eggbug", id, &mut post).await?; 25 | 26 | // Good job! 27 | ``` 28 | 29 | ## License 30 | 31 | eggbug-rs is released under the terms of the Anti-Capitalist Software License, version 1.4. 32 | 33 | ## Maintenance 34 | 35 | eggbug-rs is "lightly maintained": pull requests are generally merged quickly and without 36 | testing or API review, and new releases will generally be "breaking" (e.g. 0.2.x -> 0.3.x). 37 | -------------------------------------------------------------------------------- /examples/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use eggbug::{PostId, Session}; 3 | use tracing_subscriber::{fmt, EnvFilter}; 4 | 5 | #[tokio::main] 6 | async fn main() -> Result<()> { 7 | dotenv::dotenv().ok(); 8 | fmt().with_env_filter(EnvFilter::from_default_env()).init(); 9 | 10 | let email = std::env::var("COHOST_EMAIL")?; 11 | let password = std::env::var("COHOST_PASSWORD")?; 12 | let project = std::env::var("COHOST_PROJECT")?; 13 | let post_id = PostId( 14 | std::env::args() 15 | .nth(1) 16 | .context("usage: delete POST_ID")? 17 | .parse() 18 | .context("failed to parse post ID")?, 19 | ); 20 | 21 | let session = Session::login(&email, &password).await?; 22 | session.delete_post(&project, post_id).await?; 23 | 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /examples/get.rs: -------------------------------------------------------------------------------- 1 | #![deny(elided_lifetimes_in_paths)] 2 | #![warn(clippy::pedantic)] 3 | #![allow(clippy::uninlined_format_args)] 4 | 5 | use anyhow::Result; 6 | use eggbug::Client; 7 | use tracing_subscriber::{fmt, EnvFilter}; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<()> { 11 | dotenv::dotenv().ok(); 12 | fmt().with_env_filter(EnvFilter::from_default_env()).init(); 13 | 14 | let project = std::env::var("COHOST_PROJECT")?; 15 | 16 | let client = Client::new(); 17 | let posts = client.get_posts_page(&project, 0).await?; 18 | println!("{:#?}", posts); 19 | 20 | Ok(()) 21 | } 22 | -------------------------------------------------------------------------------- /examples/post.rs: -------------------------------------------------------------------------------- 1 | #![deny(elided_lifetimes_in_paths)] 2 | #![warn(clippy::pedantic)] 3 | 4 | use anyhow::Result; 5 | use eggbug::{Attachment, Client, Post}; 6 | use std::path::Path; 7 | use tracing_subscriber::{fmt, EnvFilter}; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<()> { 11 | dotenv::dotenv().ok(); 12 | fmt().with_env_filter(EnvFilter::from_default_env()).init(); 13 | 14 | let email = std::env::var("COHOST_EMAIL")?; 15 | let password = std::env::var("COHOST_PASSWORD")?; 16 | let project = std::env::var("COHOST_PROJECT")?; 17 | 18 | let client = Client::new(); 19 | let session = client.login(&email, &password).await?; 20 | 21 | let mut post = Post { 22 | headline: "test from eggbug-rs".into(), 23 | attachments: vec![Attachment::new_from_file( 24 | Path::new(env!("CARGO_MANIFEST_DIR")) 25 | .join("examples") 26 | .join("uh-oh.png"), 27 | "image/png".into(), 28 | None, 29 | ) 30 | .await? 31 | .with_alt_text("eggbug with question mark".into())], 32 | draft: false, 33 | ..Default::default() 34 | }; 35 | let id = session.create_post(&project, &mut post).await?; 36 | 37 | post.markdown = "yahoo\n\n---\n\nread more works!".into(); 38 | session.edit_post(&project, id, &mut post).await?; 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /examples/share.rs: -------------------------------------------------------------------------------- 1 | #![deny(elided_lifetimes_in_paths)] 2 | #![warn(clippy::pedantic)] 3 | 4 | use anyhow::Result; 5 | use eggbug::{Client, Post}; 6 | use tracing_subscriber::{fmt, EnvFilter}; 7 | 8 | #[tokio::main] 9 | async fn main() -> Result<()> { 10 | dotenv::dotenv().ok(); 11 | fmt().with_env_filter(EnvFilter::from_default_env()).init(); 12 | 13 | let email = std::env::var("COHOST_EMAIL")?; 14 | let password = std::env::var("COHOST_PASSWORD")?; 15 | let project = std::env::var("COHOST_PROJECT")?; 16 | 17 | let client = Client::new(); 18 | let session = client.login(&email, &password).await?; 19 | 20 | let mut post = Post { 21 | markdown: "wow".into(), 22 | ..Default::default() 23 | }; 24 | session 25 | .share_post(&project, 59547.into(), &mut post) 26 | .await?; 27 | 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /examples/uh-oh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iliana/eggbug-rs/b58ce9995bfbe7088bcfff40b17707c03777bb30/examples/uh-oh.png -------------------------------------------------------------------------------- /samples/example.project.posts.json: -------------------------------------------------------------------------------- 1 | { 2 | "nItems": 3, 3 | "nPages": 1, 4 | "items": [ 5 | { 6 | "postId": 185922, 7 | "headline": "Commentary repost of a post from an adult account from a non adult account", 8 | "publishedAt": "2022-11-04T03:29:25.010Z", 9 | "filename": "185922-commentary-repost-of", 10 | "transparentShareOfPostId": null, 11 | "state": 1, 12 | "numComments": 2, 13 | "numSharedComments": 1, 14 | "cws": [], 15 | "tags": [], 16 | "blocks": [ 17 | { 18 | "type": "markdown", 19 | "markdown": { 20 | "content": "and it is marked as adult content" 21 | } 22 | } 23 | ], 24 | "plainTextBody": "and it is marked as adult content", 25 | "postingProject": { 26 | "handle": "example", 27 | "displayName": "Example Page", 28 | "dek": "for use in documentation", 29 | "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", 30 | "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", 31 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", 32 | "headerURL": null, 33 | "headerPreviewURL": null, 34 | "projectId": 49507, 35 | "privacy": "public", 36 | "pronouns": "", 37 | "url": "https://www.rfc-editor.org/rfc/rfc2606.html", 38 | "flags": [], 39 | "avatarShape": "circle" 40 | }, 41 | "shareTree": [ 42 | { 43 | "postId": 185857, 44 | "headline": "This is an adult post.", 45 | "publishedAt": "2022-11-04T03:20:56.978Z", 46 | "filename": "185857-this-is-an-adult-pos", 47 | "transparentShareOfPostId": null, 48 | "state": 1, 49 | "numComments": 1, 50 | "numSharedComments": 0, 51 | "cws": [ 52 | "a content warning", 53 | "another content warning", 54 | "," 55 | ], 56 | "tags": [ 57 | "example tag", 58 | "another example tag", 59 | "woo spooky adult post" 60 | ], 61 | "blocks": [ 62 | { 63 | "type": "markdown", 64 | "markdown": { 65 | "content": "It's adult because it's on an adult account. It's also got Content Warnings." 66 | } 67 | } 68 | ], 69 | "plainTextBody": "It's adult because it's on an adult account. It's also got Content Warnings.", 70 | "postingProject": { 71 | "handle": "example-adult", 72 | "displayName": "", 73 | "dek": "", 74 | "description": "", 75 | "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", 76 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", 77 | "headerURL": null, 78 | "headerPreviewURL": null, 79 | "projectId": 49508, 80 | "privacy": "public", 81 | "pronouns": null, 82 | "url": null, 83 | "flags": [], 84 | "avatarShape": "circle" 85 | }, 86 | "shareTree": [], 87 | "relatedProjects": [], 88 | "singlePostPageUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos", 89 | "effectiveAdultContent": false, 90 | "isEditor": false, 91 | "contributorBlockIncomingOrOutgoing": false, 92 | "hasAnyContributorMuted": false, 93 | "postEditUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos/edit", 94 | "isLiked": false, 95 | "canShare": false, 96 | "canPublish": true, 97 | "hasCohostPlus": true, 98 | "pinned": false, 99 | "commentsLocked": false 100 | } 101 | ], 102 | "relatedProjects": [ 103 | { 104 | "handle": "example", 105 | "displayName": "Example Page", 106 | "dek": "for use in documentation", 107 | "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", 108 | "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", 109 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", 110 | "headerURL": null, 111 | "headerPreviewURL": null, 112 | "projectId": 49507, 113 | "privacy": "public", 114 | "pronouns": "", 115 | "url": "https://www.rfc-editor.org/rfc/rfc2606.html", 116 | "flags": [], 117 | "avatarShape": "circle" 118 | }, 119 | { 120 | "handle": "example-adult", 121 | "displayName": "", 122 | "dek": "", 123 | "description": "", 124 | "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", 125 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", 126 | "headerURL": null, 127 | "headerPreviewURL": null, 128 | "projectId": 49508, 129 | "privacy": "public", 130 | "pronouns": null, 131 | "url": null, 132 | "flags": [], 133 | "avatarShape": "circle" 134 | } 135 | ], 136 | "singlePostPageUrl": "https://cohost.org/example/post/185922-commentary-repost-of", 137 | "effectiveAdultContent": true, 138 | "isEditor": false, 139 | "contributorBlockIncomingOrOutgoing": false, 140 | "hasAnyContributorMuted": false, 141 | "postEditUrl": "https://cohost.org/example/post/185922-commentary-repost-of/edit", 142 | "isLiked": false, 143 | "canShare": false, 144 | "canPublish": true, 145 | "hasCohostPlus": true, 146 | "pinned": false, 147 | "commentsLocked": false 148 | }, 149 | { 150 | "postId": 185916, 151 | "headline": "Commentary repost of a post from an adult account from a non adult account", 152 | "publishedAt": "2022-11-04T03:28:49.206Z", 153 | "filename": "185916-commentary-repost-of", 154 | "transparentShareOfPostId": null, 155 | "state": 1, 156 | "numComments": 0, 157 | "numSharedComments": 1, 158 | "cws": [], 159 | "tags": [], 160 | "blocks": [ 161 | { 162 | "type": "markdown", 163 | "markdown": { 164 | "content": "and it's not marked as adult content" 165 | } 166 | } 167 | ], 168 | "plainTextBody": "and it's not marked as adult content", 169 | "postingProject": { 170 | "handle": "example", 171 | "displayName": "Example Page", 172 | "dek": "for use in documentation", 173 | "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", 174 | "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", 175 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", 176 | "headerURL": null, 177 | "headerPreviewURL": null, 178 | "projectId": 49507, 179 | "privacy": "public", 180 | "pronouns": "", 181 | "url": "https://www.rfc-editor.org/rfc/rfc2606.html", 182 | "flags": [], 183 | "avatarShape": "circle" 184 | }, 185 | "shareTree": [ 186 | { 187 | "postId": 185857, 188 | "headline": "This is an adult post.", 189 | "publishedAt": "2022-11-04T03:20:56.978Z", 190 | "filename": "185857-this-is-an-adult-pos", 191 | "transparentShareOfPostId": null, 192 | "state": 1, 193 | "numComments": 1, 194 | "numSharedComments": 0, 195 | "cws": [ 196 | "a content warning", 197 | "another content warning", 198 | "," 199 | ], 200 | "tags": [ 201 | "example tag", 202 | "another example tag", 203 | "woo spooky adult post" 204 | ], 205 | "blocks": [ 206 | { 207 | "type": "markdown", 208 | "markdown": { 209 | "content": "It's adult because it's on an adult account. It's also got Content Warnings." 210 | } 211 | } 212 | ], 213 | "plainTextBody": "It's adult because it's on an adult account. It's also got Content Warnings.", 214 | "postingProject": { 215 | "handle": "example-adult", 216 | "displayName": "", 217 | "dek": "", 218 | "description": "", 219 | "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", 220 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", 221 | "headerURL": null, 222 | "headerPreviewURL": null, 223 | "projectId": 49508, 224 | "privacy": "public", 225 | "pronouns": null, 226 | "url": null, 227 | "flags": [], 228 | "avatarShape": "circle" 229 | }, 230 | "shareTree": [], 231 | "relatedProjects": [], 232 | "singlePostPageUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos", 233 | "effectiveAdultContent": false, 234 | "isEditor": false, 235 | "contributorBlockIncomingOrOutgoing": false, 236 | "hasAnyContributorMuted": false, 237 | "postEditUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos/edit", 238 | "isLiked": false, 239 | "canShare": false, 240 | "canPublish": true, 241 | "hasCohostPlus": true, 242 | "pinned": false, 243 | "commentsLocked": false 244 | } 245 | ], 246 | "relatedProjects": [ 247 | { 248 | "handle": "example", 249 | "displayName": "Example Page", 250 | "dek": "for use in documentation", 251 | "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", 252 | "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", 253 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", 254 | "headerURL": null, 255 | "headerPreviewURL": null, 256 | "projectId": 49507, 257 | "privacy": "public", 258 | "pronouns": "", 259 | "url": "https://www.rfc-editor.org/rfc/rfc2606.html", 260 | "flags": [], 261 | "avatarShape": "circle" 262 | }, 263 | { 264 | "handle": "example-adult", 265 | "displayName": "", 266 | "dek": "", 267 | "description": "", 268 | "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", 269 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", 270 | "headerURL": null, 271 | "headerPreviewURL": null, 272 | "projectId": 49508, 273 | "privacy": "public", 274 | "pronouns": null, 275 | "url": null, 276 | "flags": [], 277 | "avatarShape": "circle" 278 | } 279 | ], 280 | "singlePostPageUrl": "https://cohost.org/example/post/185916-commentary-repost-of", 281 | "effectiveAdultContent": false, 282 | "isEditor": false, 283 | "contributorBlockIncomingOrOutgoing": false, 284 | "hasAnyContributorMuted": false, 285 | "postEditUrl": "https://cohost.org/example/post/185916-commentary-repost-of/edit", 286 | "isLiked": false, 287 | "canShare": false, 288 | "canPublish": true, 289 | "hasCohostPlus": true, 290 | "pinned": false, 291 | "commentsLocked": false 292 | }, 293 | { 294 | "postId": 185838, 295 | "headline": "This is a test post.", 296 | "publishedAt": "2022-11-04T03:17:49.605Z", 297 | "filename": "185838-this-is-a-test-post", 298 | "transparentShareOfPostId": null, 299 | "state": 1, 300 | "numComments": 0, 301 | "numSharedComments": 0, 302 | "cws": [], 303 | "tags": [ 304 | "test tag one", 305 | "test tag two", 306 | "a very long tag with some symbols &^^$^(*(&^*& in it" 307 | ], 308 | "blocks": [ 309 | { 310 | "type": "attachment", 311 | "attachment": { 312 | "fileURL": "https://staging.cohostcdn.org/attachment/2b1e7477-ba13-4f7e-9547-f0e2668b92b6/cooltext422710535227689.png", 313 | "previewURL": "https://staging.cohostcdn.org/attachment/2b1e7477-ba13-4f7e-9547-f0e2668b92b6/cooltext422710535227689.png", 314 | "attachmentId": "2b1e7477-ba13-4f7e-9547-f0e2668b92b6", 315 | "altText": "Stylized text with stars reading: \"this block is an image attachment\"" 316 | } 317 | }, 318 | { 319 | "type": "markdown", 320 | "markdown": { 321 | "content": "Here's the body of the test post! This should form the first block." 322 | } 323 | }, 324 | { 325 | "type": "markdown", 326 | "markdown": { 327 | "content": "This is a second paragraph of the test post, which should form the second block and includes _meaningful_*markdown* **formatting**." 328 | } 329 | }, 330 | { 331 | "type": "markdown", 332 | "markdown": { 333 | "content": "This third paragraph, forming the third block, contains Raw HTML ." 334 | } 335 | } 336 | ], 337 | "plainTextBody": "Here's the body of the test post! This should form the first block.\n\nThis is a second paragraph of the test post, which should form the second block and includes _meaningful_*markdown* **formatting**.\n\nThis third paragraph, forming the third block, contains Raw HTML .", 338 | "postingProject": { 339 | "handle": "example", 340 | "displayName": "Example Page", 341 | "dek": "for use in documentation", 342 | "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", 343 | "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", 344 | "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", 345 | "headerURL": null, 346 | "headerPreviewURL": null, 347 | "projectId": 49507, 348 | "privacy": "public", 349 | "pronouns": "", 350 | "url": "https://www.rfc-editor.org/rfc/rfc2606.html", 351 | "flags": [], 352 | "avatarShape": "circle" 353 | }, 354 | "shareTree": [], 355 | "relatedProjects": [], 356 | "singlePostPageUrl": "https://cohost.org/example/post/185838-this-is-a-test-post", 357 | "effectiveAdultContent": false, 358 | "isEditor": false, 359 | "contributorBlockIncomingOrOutgoing": false, 360 | "hasAnyContributorMuted": false, 361 | "postEditUrl": "https://cohost.org/example/post/185838-this-is-a-test-post/edit", 362 | "isLiked": false, 363 | "canShare": false, 364 | "canPublish": true, 365 | "hasCohostPlus": true, 366 | "pinned": false, 367 | "commentsLocked": false 368 | } 369 | ], 370 | "_links": [ 371 | { 372 | "href": "/api/v1/project/example", 373 | "rel": "project", 374 | "type": "GET" 375 | } 376 | ] 377 | } 378 | -------------------------------------------------------------------------------- /src/ask.rs: -------------------------------------------------------------------------------- 1 | use derive_more::{Display, From, FromStr, Into}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// An ask ID. 5 | #[derive( 6 | Clone, 7 | Debug, 8 | Default, 9 | Deserialize, 10 | Display, 11 | Eq, 12 | From, 13 | FromStr, 14 | Hash, 15 | Into, 16 | Ord, 17 | PartialEq, 18 | PartialOrd, 19 | Serialize, 20 | )] 21 | #[serde(transparent)] 22 | pub struct AskId(pub String); 23 | 24 | /// Describes the contents of an ask. Asks can't be created client-side, only decoded when reading 25 | /// content from the server. 26 | #[derive(Clone, Debug)] 27 | pub struct Ask { 28 | pub(crate) ask_id: AskId, 29 | /// Information about the account that sent this ask, if it wasn't sent anonymously. 30 | pub asker: Option, 31 | /// Markdown content for the ask, displayed after the asker's name. 32 | pub content: String, 33 | /// The date and time this ask was sent. 34 | pub sent_at: chrono::DateTime, 35 | } 36 | 37 | impl Ask { 38 | /// Get the ID of the ask represented by this struct. 39 | #[must_use] 40 | pub fn id(&self) -> &str { 41 | &self.ask_id.0 42 | } 43 | } 44 | 45 | /// Describes the project that sent an ask. 46 | #[derive(Clone, Debug)] 47 | pub struct Asker { 48 | /// The unique handle of the asker. 49 | pub handle: String, 50 | /// The display name of the asker, which may be different from the handle. 51 | pub display_name: String, 52 | } 53 | -------------------------------------------------------------------------------- /src/attachment.rs: -------------------------------------------------------------------------------- 1 | use crate::{Client, Error, PostId}; 2 | use bytes::Bytes; 3 | use derive_more::{Display, From, FromStr, Into}; 4 | use reqwest::multipart::{Form, Part}; 5 | use reqwest::Body; 6 | use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; 7 | use std::collections::HashMap; 8 | use uuid::Uuid; 9 | 10 | /// An attachment ID. 11 | #[allow(clippy::module_name_repetitions)] 12 | #[derive( 13 | Clone, 14 | Copy, 15 | Debug, 16 | Default, 17 | Deserialize, 18 | Display, 19 | Eq, 20 | From, 21 | FromStr, 22 | Hash, 23 | Into, 24 | Ord, 25 | PartialEq, 26 | PartialOrd, 27 | Serialize, 28 | )] 29 | #[serde(transparent)] 30 | pub struct AttachmentId(pub Uuid); 31 | 32 | /// Describes an attachment. 33 | /// 34 | /// Attachments are created in the ["new"][`Attachment::is_new`] state. When part of a 35 | /// [`Post`][`crate::Post`] that is created or edited, the client attempts to upload the 36 | /// attachment. If successful, the attachment becomes ["uploaded"][`Attachment::is_uploaded`]; if 37 | /// not, the attachment becomes ["failed"][`Attachment::is_failed`]. 38 | #[derive(Debug)] 39 | pub struct Attachment { 40 | pub(crate) kind: Inner, 41 | 42 | /// Alt text associated with this attachment. 43 | pub alt_text: Option, 44 | } 45 | 46 | /// Attachment metadata specific to a supported type of media. 47 | #[derive(Debug, Serialize)] 48 | #[serde(untagged)] 49 | pub enum MediaMetadata { 50 | /// Image attachments 51 | Image { 52 | /// Image width 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | width: Option, 55 | /// Image height 56 | #[serde(skip_serializing_if = "Option::is_none")] 57 | height: Option, 58 | }, 59 | /// Audio attachments 60 | #[serde(serialize_with = "serialize_audio_metadata")] 61 | Audio { 62 | /// Audio artist 63 | artist: String, 64 | /// Audio title 65 | title: String, 66 | }, 67 | } 68 | 69 | #[derive(Debug)] 70 | pub(crate) enum Inner { 71 | New { 72 | stream: Body, 73 | filename: String, 74 | content_type: String, 75 | content_length: u64, 76 | metadata: Option, 77 | }, 78 | Uploaded(Finished), 79 | Failed, 80 | } 81 | 82 | #[derive(Debug, Deserialize)] 83 | #[serde(rename_all = "camelCase")] 84 | pub(crate) struct Finished { 85 | pub(crate) attachment_id: AttachmentId, 86 | pub(crate) url: String, 87 | } 88 | 89 | impl Attachment { 90 | /// Create an `Attachment` from a buffer. 91 | /// 92 | /// # Panics 93 | /// 94 | /// Panics if the length of `content` overflows a [`u64`]. 95 | pub fn new( 96 | content: impl Into, 97 | filename: String, 98 | content_type: String, 99 | metadata: MediaMetadata, 100 | ) -> Attachment { 101 | let content: Bytes = content.into(); 102 | 103 | let alt_text = if let MediaMetadata::Image { .. } = metadata { 104 | Some(String::new()) 105 | } else { 106 | None 107 | }; 108 | 109 | Attachment { 110 | kind: Inner::New { 111 | content_length: content.len().try_into().unwrap(), 112 | stream: content.into(), 113 | filename, 114 | content_type, 115 | metadata: Some(metadata), 116 | }, 117 | alt_text, 118 | } 119 | } 120 | 121 | /// Create an `Attachment` from a file on disk. 122 | #[cfg(feature = "fs")] 123 | pub async fn new_from_file( 124 | path: impl AsRef, 125 | content_type: String, 126 | metadata: Option, 127 | ) -> Result { 128 | use tokio::fs::File; 129 | use tokio_util::codec::{BytesCodec, FramedRead}; 130 | 131 | let path = path.as_ref(); 132 | let filename = path 133 | .file_name() 134 | .and_then(std::ffi::OsStr::to_str) 135 | .unwrap_or("file") 136 | .to_owned(); 137 | 138 | let file = File::open(path).await?; 139 | let content_length = file.metadata().await?.len(); 140 | let stream = Body::wrap_stream(FramedRead::new(file, BytesCodec::new())); 141 | 142 | let metadata = if metadata.is_some() { 143 | metadata 144 | } else if content_type.starts_with("image/") { 145 | #[cfg(not(feature = "imagesize"))] 146 | let (width, height) = (None, None); 147 | 148 | #[cfg(feature = "imagesize")] 149 | let (width, height) = match imagesize::size(path) { 150 | Ok(dim) => ( 151 | Some(u32::try_from(dim.width).expect("width overflow")), 152 | Some(u32::try_from(dim.height).expect("height overflow")), 153 | ), 154 | Err(_) => (None, None), 155 | }; 156 | 157 | Some(MediaMetadata::Image { width, height }) 158 | } else if content_type.starts_with("audio/") { 159 | Some(MediaMetadata::Audio { 160 | artist: String::new(), 161 | title: String::new(), 162 | }) 163 | } else { 164 | None 165 | }; 166 | 167 | let alt_text = if let Some(MediaMetadata::Image { .. }) = metadata { 168 | Some(String::new()) 169 | } else { 170 | None 171 | }; 172 | 173 | Ok(Attachment { 174 | kind: Inner::New { 175 | stream, 176 | filename, 177 | content_type, 178 | content_length, 179 | metadata, 180 | }, 181 | alt_text, 182 | }) 183 | } 184 | 185 | /// Sets new alt text in a builder-style function. 186 | #[must_use] 187 | pub fn with_alt_text(self, alt_text: String) -> Attachment { 188 | Attachment { 189 | kind: self.kind, 190 | alt_text: Some(alt_text), 191 | } 192 | } 193 | 194 | /// Returns true if the attachment has not yet been uploaded. 195 | pub fn is_new(&self) -> bool { 196 | matches!(self.kind, Inner::New { .. }) 197 | } 198 | 199 | /// Returns true if the attachment is uploaded. 200 | pub fn is_uploaded(&self) -> bool { 201 | matches!(self.kind, Inner::Uploaded { .. }) 202 | } 203 | 204 | /// Returns true if the attachment failed to upload. Failed attachments cannot be recovered. 205 | pub fn is_failed(&self) -> bool { 206 | matches!(self.kind, Inner::Failed) 207 | } 208 | 209 | /// If the attachment is uploaded, returns the CDN URL. 210 | pub fn url(&self) -> Option<&str> { 211 | match &self.kind { 212 | Inner::Uploaded(Finished { url, .. }) => Some(url), 213 | _ => None, 214 | } 215 | } 216 | 217 | pub(crate) fn id(&self) -> Option { 218 | match self.kind { 219 | Inner::Uploaded(Finished { attachment_id, .. }) => Some(attachment_id), 220 | _ => None, 221 | } 222 | } 223 | 224 | #[tracing::instrument(skip(client))] 225 | pub(crate) async fn upload( 226 | &mut self, 227 | client: &Client, 228 | project: &str, 229 | id: PostId, 230 | ) -> Result<(), Error> { 231 | let (stream, filename, content_type, content_length, metadata) = 232 | match std::mem::replace(&mut self.kind, Inner::Failed) { 233 | Inner::New { 234 | stream, 235 | filename, 236 | content_type, 237 | content_length, 238 | metadata, 239 | } => (stream, filename, content_type, content_length, metadata), 240 | Inner::Uploaded(_) => return Ok(()), 241 | Inner::Failed => return Err(Error::FailedAttachment), 242 | }; 243 | 244 | let TrpcResponse { 245 | result: TrpcData { data: response }, 246 | }: TrpcResponse = client 247 | .post("trpc/posts.attachment.start") 248 | .json(&AttachStartRequest { 249 | project_handle: project, 250 | post_id: id, 251 | filename: &filename, 252 | content_type: &content_type, 253 | content_length, 254 | metadata, 255 | }) 256 | .send() 257 | .await? 258 | .error_for_status()? 259 | .json() 260 | .await?; 261 | tracing::info!(attachment_id = %response.attachment_id); 262 | 263 | let mut form = Form::new(); 264 | for (name, value) in response.required_fields { 265 | form = form.text(name, value); 266 | } 267 | form = form.part( 268 | "file", 269 | Part::stream_with_length(stream, content_length) 270 | .file_name(filename) 271 | .mime_str(&content_type)?, 272 | ); 273 | 274 | client 275 | .client 276 | .post(response.url) 277 | .multipart(form) 278 | .send() 279 | .await? 280 | .error_for_status()?; 281 | 282 | self.kind = Inner::Uploaded( 283 | client 284 | .post(&format!( 285 | "project/{}/posts/{}/attach/finish/{}", 286 | project, id, response.attachment_id 287 | )) 288 | .send() 289 | .await? 290 | .error_for_status()? 291 | .json() 292 | .await?, 293 | ); 294 | Ok(()) 295 | } 296 | } 297 | 298 | #[derive(Serialize)] 299 | #[serde(rename_all = "camelCase")] 300 | struct AttachStartRequest<'a> { 301 | project_handle: &'a str, 302 | post_id: PostId, 303 | 304 | filename: &'a str, 305 | content_type: &'a str, 306 | content_length: u64, 307 | 308 | #[serde(flatten, skip_serializing_if = "Option::is_none")] 309 | metadata: Option, 310 | } 311 | 312 | #[derive(Serialize)] 313 | struct AudioMetadata<'a> { 314 | artist: &'a str, 315 | title: &'a str, 316 | } 317 | 318 | fn serialize_audio_metadata( 319 | artist: &str, 320 | title: &str, 321 | serializer: S, 322 | ) -> Result { 323 | let mut map = serializer.serialize_map(Some(1))?; 324 | map.serialize_entry("metadata", &AudioMetadata { artist, title })?; 325 | map.end() 326 | } 327 | 328 | #[derive(Deserialize)] 329 | #[serde(rename_all = "camelCase")] 330 | struct AttachStartResponse { 331 | attachment_id: AttachmentId, 332 | url: String, 333 | required_fields: HashMap, 334 | } 335 | 336 | #[derive(Deserialize)] 337 | struct TrpcResponse { 338 | result: TrpcData, 339 | } 340 | 341 | #[derive(Deserialize)] 342 | struct TrpcData { 343 | data: D, 344 | } 345 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Post, Session}; 2 | use reqwest::{Method, RequestBuilder}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::borrow::Cow; 5 | 6 | const PBKDF2_ITERATIONS: u32 = 200_000; 7 | const PBKDF2_KEY_LENGTH: usize = 128; 8 | 9 | macro_rules! request_impl { 10 | ($($f:ident),* $(,)*) => { 11 | $( 12 | #[inline] 13 | pub(crate) fn $f(&self, path: &str) -> RequestBuilder { 14 | tracing::info!(path, concat!("Client::", stringify!($f))); 15 | self.client.$f(format!("{}{}", self.base_url, path)) 16 | } 17 | )* 18 | }; 19 | } 20 | 21 | /// HTTP client. 22 | #[derive(Debug, Clone)] 23 | pub struct Client { 24 | pub(crate) base_url: Cow<'static, str>, 25 | pub(crate) client: reqwest::Client, 26 | logged_in: bool, 27 | } 28 | 29 | impl Client { 30 | /// Creates a new `Client` with the default base URL, `https://cohost.org/api/v1/`. Use 31 | /// [`Client::with_base_url`] to change the base URL. 32 | #[must_use] 33 | #[allow(clippy::missing_panics_doc)] // tested to not panic 34 | pub fn new() -> Client { 35 | const USER_AGENT: &str = concat!( 36 | "eggbug-rs/", 37 | env!("CARGO_PKG_VERSION"), 38 | " (https://github.com/iliana/eggbug-rs)", 39 | ); 40 | 41 | Client { 42 | base_url: Cow::Borrowed("https://cohost.org/api/v1/"), 43 | client: reqwest::Client::builder() 44 | .cookie_store(true) 45 | .user_agent(USER_AGENT) 46 | .build() 47 | .unwrap(), 48 | logged_in: false, 49 | } 50 | } 51 | 52 | /// Creates a new `Client` with a custom base URL. 53 | #[must_use] 54 | pub fn with_base_url(mut self, mut base_url: String) -> Client { 55 | if !base_url.ends_with('/') { 56 | base_url.push('/'); 57 | } 58 | self.base_url = Cow::Owned(base_url); 59 | self 60 | } 61 | 62 | /// Logs into cohost with an email and password, returning a [`Session`]. 63 | /// 64 | /// Securely storing the user's password is an exercise left to the caller. 65 | #[tracing::instrument(skip(self, password))] 66 | pub async fn login(mut self, email: &str, password: &str) -> Result { 67 | let SaltResponse { salt } = self 68 | .get("login/salt") 69 | .query(&[("email", email)]) 70 | .send() 71 | .await? 72 | .error_for_status()? 73 | .json() 74 | .await?; 75 | 76 | let mut client_hash = [0; PBKDF2_KEY_LENGTH]; 77 | pbkdf2::pbkdf2::>( 78 | password.as_bytes(), 79 | &decode_salt(&salt)?, 80 | PBKDF2_ITERATIONS, 81 | &mut client_hash, 82 | ); 83 | let client_hash = base64::encode(client_hash); 84 | 85 | let LoginResponse { user_id } = self 86 | .post("login") 87 | .json(&LoginRequest { email, client_hash }) 88 | .send() 89 | .await? 90 | .error_for_status()? 91 | .json() 92 | .await?; 93 | tracing::info!(user_id, "logged in"); 94 | self.logged_in = true; 95 | 96 | Ok(Session { client: self }) 97 | } 98 | 99 | /// Returns true if this client has logged in before. 100 | /// 101 | /// This can be used to differentiate a reference as returned from [`Session::as_client`] or as 102 | /// never logged in. 103 | #[must_use] 104 | pub fn has_logged_in(&self) -> bool { 105 | self.logged_in 106 | } 107 | 108 | /// Get a page of posts from the given project. 109 | /// 110 | /// Pages start at 0. Once you get an empty page, there are no more pages after that to get; 111 | /// they will all be empty. 112 | #[tracing::instrument(skip(self))] 113 | pub async fn get_posts_page(&self, project: &str, page: u64) -> Result, Error> { 114 | let posts_page: crate::post::PostPage = self 115 | .get(&format!("project/{}/posts", project)) 116 | .query(&[("page", page.to_string())]) 117 | .send() 118 | .await? 119 | .error_for_status()? 120 | .json() 121 | .await?; 122 | Ok(posts_page.into()) 123 | } 124 | 125 | #[inline] 126 | pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder { 127 | tracing::info!(%method, path, "Client::request"); 128 | self.client 129 | .request(method, format!("{}{}", self.base_url, path)) 130 | } 131 | 132 | request_impl!(delete, get, post, put); 133 | } 134 | 135 | impl Default for Client { 136 | fn default() -> Client { 137 | Client::new() 138 | } 139 | } 140 | 141 | /// There is a subtle bug(?) in cohost: 142 | /// - The salt returned from the `login/salt` endpoint returns a string that _appears_ to be 143 | /// using the URL-safe Base64 alphabet with no padding. 144 | /// - However, the salt is being decoded with some JavaScript code that uses the standard 145 | /// (`+/`) alphabet. 146 | /// - This code uses a lookup table to go from a Base64 character to a 6-bit value. If the 147 | /// character is not in the lookup table, the lookup returns `undefined`. The code then 148 | /// performs bitwise operations on the returned value, which is coerced to 0 if not present 149 | /// in the lookup table. 150 | /// 151 | /// We can replicate this effect by replacing hyphens and underscores with the `A`, the 152 | /// Base64 character representing 0. 153 | /// 154 | /// mogery seemed to know about this when writing cohost.js (see lib/b64arraybuffer.js): 155 | /// 156 | fn decode_salt(salt: &str) -> Result, Error> { 157 | Ok(base64::decode_config( 158 | salt.replace(['-', '_'], "A"), 159 | base64::STANDARD_NO_PAD, 160 | )?) 161 | } 162 | 163 | #[cfg(test)] 164 | #[test] 165 | fn test_decode_salt() { 166 | assert_eq!( 167 | decode_salt("JGhosofJGYFsyBlZspFVYg").unwrap(), 168 | base64::decode_config("JGhosofJGYFsyBlZspFVYg", base64::URL_SAFE_NO_PAD).unwrap() 169 | ); 170 | assert_eq!( 171 | decode_salt("dg6y2aIj_iKzcgaL_MM8_Q").unwrap(), 172 | base64::decode_config("dg6y2aIjAiKzcgaLAMM8AQ", base64::URL_SAFE_NO_PAD).unwrap() 173 | ); 174 | } 175 | 176 | #[derive(Deserialize)] 177 | #[serde(rename_all = "camelCase")] 178 | struct SaltResponse { 179 | salt: String, 180 | } 181 | 182 | #[derive(Serialize)] 183 | #[serde(rename_all = "camelCase")] 184 | struct LoginRequest<'a> { 185 | email: &'a str, 186 | client_hash: String, 187 | } 188 | 189 | #[derive(Deserialize)] 190 | #[serde(rename_all = "camelCase")] 191 | struct LoginResponse { 192 | user_id: u64, 193 | } 194 | 195 | #[cfg(test)] 196 | mod tests { 197 | use super::Client; 198 | 199 | #[test] 200 | fn client_new_doesnt_panic() { 201 | drop(Client::new()); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | /// Errors that might occur when using the library. 2 | #[derive(Debug, thiserror::Error)] 3 | #[non_exhaustive] 4 | pub enum Error { 5 | /// Attempted to create or edit a post with no headline, attachments, or markdown content. 6 | #[error("post is empty (no headline, attachments, or markdown)")] 7 | EmptyPost, 8 | 9 | /// Attempted to create or edit a post with an [`Attachment`][`crate::Attachment`] marked as 10 | /// failed. 11 | #[error("attempted to use post with failed attachment")] 12 | FailedAttachment, 13 | 14 | /// An error while decoding a Base64 string. 15 | #[error("base64 decode error: {0}")] 16 | Base64Decode(#[from] base64::DecodeError), 17 | 18 | /// An I/O error. 19 | #[error("i/o error: {0}")] 20 | Io(#[from] std::io::Error), 21 | 22 | /// An HTTP client error (including status codes indicating failure). 23 | #[error("request error: {0}")] 24 | Request(#[from] reqwest::Error), 25 | } 26 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! eggbug-rs is a bot library for [cohost.org](https://cohost.org/rc/welcome), providing an 2 | //! interface to create, read, edit, and delete posts. 3 | //! 4 | //! ```no_run 5 | //! use eggbug::{Post, Session}; 6 | //! 7 | //! # async fn f() -> Result<(), Box> { 8 | //! // Log in 9 | //! let session = Session::login("eggbug@website.invalid", "hunter2").await?; 10 | //! 11 | //! // Describe a post 12 | //! let mut post = Post { 13 | //! headline: "hello from eggbug-rs!".into(), 14 | //! markdown: "wow it's like a website in here".into(), 15 | //! ..Default::default() 16 | //! }; 17 | //! 18 | //! // Create the post on the eggbug page 19 | //! let id = session.create_post("eggbug", &mut post).await?; 20 | //! 21 | //! // Oh wait we want to make that a link 22 | //! post.markdown = "wow it's [like a website in here](https://cohost.org/hthrflwrs/post/25147-empty)".into(); 23 | //! session.edit_post("eggbug", id, &mut post).await?; 24 | //! 25 | //! // Good job! 26 | //! # Ok(()) 27 | //! # } 28 | //! ``` 29 | //! 30 | //! # License 31 | //! 32 | //! eggbug-rs is released under the terms of the Anti-Capitalist Software License, version 1.4. 33 | 34 | #![deny(elided_lifetimes_in_paths)] 35 | #![warn(clippy::pedantic, missing_docs)] 36 | #![allow( 37 | clippy::manual_let_else, 38 | clippy::missing_errors_doc, 39 | clippy::module_name_repetitions, 40 | clippy::uninlined_format_args 41 | )] 42 | 43 | mod ask; 44 | mod attachment; 45 | mod client; 46 | mod error; 47 | mod post; 48 | mod session; 49 | 50 | pub use crate::ask::{Ask, AskId, Asker}; 51 | pub use crate::attachment::{Attachment, AttachmentId, MediaMetadata}; 52 | pub use crate::client::Client; 53 | pub use crate::error::Error; 54 | pub use crate::post::{Post, PostId, PostLocations, PostMetadata}; 55 | pub use crate::session::Session; 56 | -------------------------------------------------------------------------------- /src/post.rs: -------------------------------------------------------------------------------- 1 | use crate::{Ask, Asker, Attachment, Error, Session}; 2 | pub(crate) use de::PostPage; 3 | use derive_more::{Display, From, FromStr, Into}; 4 | use reqwest::Method; 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt::Debug; 7 | 8 | /// A post ID. 9 | #[allow(clippy::module_name_repetitions)] 10 | #[derive( 11 | Clone, 12 | Copy, 13 | Debug, 14 | Default, 15 | Deserialize, 16 | Display, 17 | Eq, 18 | From, 19 | FromStr, 20 | Hash, 21 | Into, 22 | Ord, 23 | PartialEq, 24 | PartialOrd, 25 | Serialize, 26 | )] 27 | #[serde(transparent)] 28 | pub struct PostId(pub u64); 29 | 30 | /// Describes a post's contents. 31 | /// 32 | /// When you send a post with [`Session::create_post`] or [`Session::edit_post`], the `Post` must 33 | /// be mutable. This is because the [`attachments`][`Post::attachments`] field will be modified 34 | /// with the ID and URL of the uploaded attachment. 35 | #[derive(Debug, Default)] 36 | #[must_use] 37 | pub struct Post { 38 | /// Marks the post as [18+ content](https://help.antisoftware.club/support/solutions/articles/62000225024-what-does-adult-content-mean-). 39 | pub adult_content: bool, 40 | /// Post headline, which is displayed above attachments and markdown. 41 | pub headline: String, 42 | /// The ask to which this post is responding, if any. 43 | pub ask: Option, 44 | /// List of attachments, displayed between the headline and markdown. 45 | pub attachments: Vec, 46 | /// Markdown content for the post, displayed after the headline and attachments. 47 | pub markdown: String, 48 | /// List of tags. 49 | pub tags: Vec, 50 | /// List of content warnings. 51 | pub content_warnings: Vec, 52 | /// Marks the post as a draft, preventing it from being seen by other users without the draft 53 | /// link. 54 | pub draft: bool, 55 | /// Metadata returned by cohost from posts retrieved from the API. 56 | /// 57 | /// This field is ignored when creating or editing a post. 58 | pub metadata: Option, 59 | } 60 | 61 | /// Metadata returned by the cohost API for posts retrieved from post pages. 62 | #[derive(Debug)] 63 | #[non_exhaustive] 64 | #[allow(clippy::struct_excessive_bools, clippy::module_name_repetitions)] 65 | pub struct PostMetadata { 66 | /// All identifiers regarding where this post can be found. 67 | pub locations: PostLocations, 68 | /// True if the client has permission to share this post. 69 | pub can_share: bool, 70 | /// True if adding new comments is disabled on this post. 71 | pub comments_locked: bool, 72 | /// True if any contributor to the post is muted by the current account. 73 | pub has_any_contributor_muted: bool, 74 | /// True if cohost plus features were available to the poster. 75 | pub has_cohost_plus: bool, 76 | /// True if the current account has liked this post. 77 | pub liked: bool, 78 | /// The number of comments on this post. 79 | pub num_comments: u64, 80 | /// The number of comments on other posts in this post's branch of the 81 | /// share tree. 82 | pub num_shared_comments: u64, 83 | /// True if this post is pinned to its author's profile. 84 | pub pinned: bool, 85 | /// The handle of the project that posted this post. 86 | pub posting_project_id: String, 87 | /// The time at which the post was published. 88 | pub publication_date: chrono::DateTime, 89 | /// A list of the handles of all the projects involved in this post. 90 | pub related_projects: Vec, 91 | /// A list of all the posts in this post's branch of the share tree. 92 | pub share_tree: Vec, 93 | } 94 | 95 | /// All identifying information about where to find a post, from its ID to how to edit it. 96 | #[derive(Debug, Hash, Clone, PartialEq, Eq)] 97 | #[non_exhaustive] 98 | #[allow(clippy::module_name_repetitions)] 99 | pub struct PostLocations { 100 | /// The unique numerical ID of the post. 101 | pub id: PostId, 102 | /// The filename of the post, excluding the protocol, domain, and project. 103 | /// Acts as a unique ID with a semi-readable slug. 104 | pub filename: String, 105 | /// The complete URL at which this post can be viewed. 106 | pub url: String, 107 | /// The location at which this post can be edited. 108 | pub edit_url: String, 109 | } 110 | 111 | impl Post { 112 | /// Returns true if the post has no content (no headline, attachments, or markdown content). 113 | #[must_use] 114 | pub fn is_empty(&self) -> bool { 115 | self.attachments.is_empty() && self.headline.is_empty() && self.markdown.is_empty() 116 | } 117 | 118 | pub(crate) async fn send( 119 | &mut self, 120 | session: &Session, 121 | method: Method, 122 | path: &str, 123 | project: &str, 124 | shared_post: Option, 125 | ) -> Result { 126 | if self.is_empty() && shared_post.is_none() { 127 | return Err(Error::EmptyPost); 128 | } 129 | if self.attachments.iter().any(Attachment::is_failed) { 130 | return Err(Error::FailedAttachment); 131 | } 132 | 133 | let need_upload = self.attachments.iter().any(Attachment::is_new); 134 | 135 | let de::PostResponse { post_id } = session 136 | .client 137 | .request(method, path) 138 | .json(&self.as_api(need_upload, shared_post)) 139 | .send() 140 | .await? 141 | .error_for_status()? 142 | .json() 143 | .await?; 144 | tracing::info!(%post_id); 145 | 146 | if need_upload { 147 | futures::future::try_join_all( 148 | self.attachments 149 | .iter_mut() 150 | .map(|attachment| attachment.upload(&session.client, project, post_id)), 151 | ) 152 | .await?; 153 | 154 | session 155 | .client 156 | .put(&format!("project/{}/posts/{}", project, post_id)) 157 | .json(&self.as_api(false, shared_post)) 158 | .send() 159 | .await? 160 | .error_for_status()?; 161 | } 162 | 163 | Ok(post_id) 164 | } 165 | 166 | #[tracing::instrument] 167 | fn as_api(&self, force_draft: bool, shared_post: Option) -> ser::Post<'_> { 168 | let mut blocks = self 169 | .attachments 170 | .iter() 171 | .map(|attachment| ser::Block::Attachment { 172 | attachment: ser::Attachment { 173 | alt_text: attachment.alt_text.as_deref(), 174 | attachment_id: attachment.id().unwrap_or_default(), 175 | }, 176 | }) 177 | .collect::>(); 178 | if !self.markdown.is_empty() { 179 | for block in self.markdown.split("\n\n") { 180 | blocks.push(ser::Block::Markdown { 181 | markdown: ser::Markdown { content: block }, 182 | }); 183 | } 184 | } 185 | 186 | #[allow(clippy::bool_to_int_with_if)] 187 | let post_state = if force_draft || self.draft { 0 } else { 1 }; 188 | 189 | let post = ser::Post { 190 | adult_content: self.adult_content, 191 | blocks, 192 | cws: &self.content_warnings, 193 | headline: &self.headline, 194 | post_state, 195 | share_of_post_id: shared_post, 196 | tags: &self.tags, 197 | }; 198 | tracing::debug!(?post); 199 | post 200 | } 201 | } 202 | 203 | impl From for Post { 204 | fn from(api: de::Post) -> Self { 205 | let locations = PostLocations { 206 | id: api.post_id, 207 | filename: api.filename, 208 | url: api.single_post_page_url, 209 | edit_url: api.post_edit_url, 210 | }; 211 | let metadata = PostMetadata { 212 | locations, 213 | can_share: api.can_share, 214 | comments_locked: api.comments_locked, 215 | has_any_contributor_muted: api.has_any_contributor_muted, 216 | has_cohost_plus: api.has_cohost_plus, 217 | liked: api.is_liked, 218 | num_comments: api.num_comments, 219 | num_shared_comments: api.num_shared_comments, 220 | pinned: api.pinned, 221 | related_projects: { 222 | let mut related_projects: Vec = api 223 | .related_projects 224 | .into_iter() 225 | .map(|project| project.handle) 226 | .collect(); 227 | if related_projects.is_empty() { 228 | related_projects.push(api.posting_project.handle.clone()); 229 | }; 230 | related_projects 231 | }, 232 | posting_project_id: api.posting_project.handle, 233 | publication_date: api.published_at, 234 | share_tree: api.share_tree.into_iter().map(Post::from).collect(), 235 | }; 236 | 237 | let mut attachments: Vec = Vec::new(); 238 | let mut ask: Option = None; 239 | for block in api.blocks { 240 | match block { 241 | de::Block::Attachment { attachment } => { 242 | attachments.push(crate::attachment::Attachment::from(attachment)); 243 | } 244 | // Note: there should only be one ask per post! 245 | de::Block::Ask { ask: v } => ask = Some(crate::ask::Ask::from(v)), 246 | de::Block::Markdown { .. } => {} 247 | } 248 | } 249 | 250 | Self { 251 | metadata: Some(metadata), 252 | ask, 253 | adult_content: api.effective_adult_content, 254 | headline: api.headline, 255 | markdown: api.plain_text_body, 256 | tags: api.tags, 257 | content_warnings: api.cws, 258 | draft: api.state == 0, 259 | attachments, 260 | } 261 | } 262 | } 263 | 264 | impl From for Attachment { 265 | fn from(api: de::Attachment) -> Self { 266 | Self { 267 | kind: crate::attachment::Inner::Uploaded(crate::attachment::Finished { 268 | attachment_id: api.attachment_id, 269 | url: api.file_url, 270 | }), 271 | alt_text: api.alt_text, 272 | } 273 | } 274 | } 275 | 276 | impl From for Ask { 277 | fn from(api: de::Ask) -> Self { 278 | Self { 279 | ask_id: api.ask_id, 280 | asker: api.asking_project.map(crate::ask::Asker::from), 281 | content: api.content, 282 | sent_at: api.sent_at, 283 | } 284 | } 285 | } 286 | 287 | impl From for Asker { 288 | fn from(api: de::AskingProject) -> Self { 289 | Self { 290 | display_name: api.display_name.unwrap_or_else(|| api.handle.clone()), 291 | handle: api.handle, 292 | } 293 | } 294 | } 295 | 296 | impl From for Vec { 297 | fn from(page: PostPage) -> Self { 298 | page.items.into_iter().map(Post::from).collect() 299 | } 300 | } 301 | 302 | mod ser { 303 | use super::PostId; 304 | use crate::attachment::AttachmentId; 305 | use serde::Serialize; 306 | use std::fmt::{self, Debug}; 307 | 308 | #[derive(Serialize)] 309 | #[serde(rename_all = "camelCase")] 310 | pub struct Post<'a> { 311 | pub adult_content: bool, 312 | pub blocks: Vec>, 313 | pub cws: &'a [String], 314 | pub headline: &'a str, 315 | pub post_state: u64, 316 | #[serde(skip_serializing_if = "Option::is_none")] 317 | pub share_of_post_id: Option, 318 | pub tags: &'a [String], 319 | } 320 | 321 | impl Debug for Post<'_> { 322 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 323 | write!(f, "{}", serde_json::to_value(self).map_err(|_| fmt::Error)?) 324 | } 325 | } 326 | 327 | #[derive(Serialize)] 328 | #[serde(tag = "type", rename_all = "camelCase")] 329 | pub enum Block<'a> { 330 | Attachment { attachment: Attachment<'a> }, 331 | Markdown { markdown: Markdown<'a> }, 332 | } 333 | 334 | #[derive(Serialize)] 335 | #[serde(rename_all = "camelCase")] 336 | pub struct Attachment<'a> { 337 | #[serde(skip_serializing_if = "Option::is_none")] 338 | pub alt_text: Option<&'a str>, 339 | pub attachment_id: AttachmentId, 340 | } 341 | 342 | #[derive(Serialize)] 343 | #[serde(rename_all = "camelCase")] 344 | pub struct Markdown<'a> { 345 | pub content: &'a str, 346 | } 347 | } 348 | 349 | mod de { 350 | use super::PostId; 351 | use crate::{AskId, AttachmentId}; 352 | use serde::Deserialize; 353 | 354 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 355 | #[serde(rename_all = "camelCase")] 356 | pub struct PostPage { 357 | pub(super) n_items: u64, 358 | pub(super) n_pages: u64, 359 | pub(super) items: Vec, 360 | } 361 | 362 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 363 | #[serde(rename_all = "camelCase")] 364 | #[allow(clippy::struct_excessive_bools)] 365 | pub struct Post { 366 | pub blocks: Vec, 367 | //pub can_publish: bool, 368 | pub can_share: bool, 369 | pub comments_locked: bool, 370 | //pub contributor_block_incoming_or_outgoing: bool, 371 | pub cws: Vec, 372 | pub effective_adult_content: bool, 373 | pub filename: String, 374 | pub has_any_contributor_muted: bool, 375 | pub has_cohost_plus: bool, 376 | pub headline: String, 377 | //pub is_editor: bool, 378 | pub is_liked: bool, 379 | pub num_comments: u64, 380 | pub num_shared_comments: u64, 381 | pub pinned: bool, 382 | pub plain_text_body: String, 383 | pub post_edit_url: String, 384 | pub post_id: PostId, 385 | pub posting_project: PostingProject, 386 | pub published_at: chrono::DateTime, 387 | pub related_projects: Vec, 388 | pub share_tree: Vec, 389 | pub single_post_page_url: String, 390 | pub state: u64, 391 | pub tags: Vec, 392 | //pub transparent_share_of_post_id: Option, 393 | } 394 | 395 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 396 | #[serde(rename_all = "camelCase")] 397 | pub struct PostingProject { 398 | pub handle: String, 399 | //pub display_name: Option, 400 | //pub dek: Option, 401 | //pub description: Option, 402 | //#[serde(rename = "avatarURL")] 403 | //pub avatar_url: String, 404 | //#[serde(rename = "avatarPreviewURL")] 405 | //pub avatar_preview_url: String, 406 | //pub project_id: ProjectId, 407 | //pub privacy: String, 408 | //pub pronouns: Option, 409 | //pub url: Option, 410 | //pub avatar_shape: String, 411 | } 412 | 413 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 414 | #[serde(rename_all = "camelCase")] 415 | pub struct AskingProject { 416 | pub handle: String, 417 | pub display_name: Option, 418 | //pub dek: Option, 419 | //pub description: Option, 420 | //#[serde(rename = "avatarURL")] 421 | //pub avatar_url: String, 422 | //#[serde(rename = "avatarPreviewURL")] 423 | //pub avatar_preview_url: String, 424 | //pub project_id: ProjectId, 425 | //pub privacy: String, 426 | //pub avatar_shape: String, 427 | } 428 | 429 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 430 | #[serde(tag = "type", rename_all = "camelCase")] 431 | pub enum Block { 432 | Attachment { attachment: Attachment }, 433 | Markdown { markdown: Markdown }, 434 | Ask { ask: Ask }, 435 | } 436 | 437 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 438 | #[serde(rename_all = "camelCase")] 439 | pub struct Attachment { 440 | #[serde(default)] 441 | pub alt_text: Option, 442 | pub attachment_id: AttachmentId, 443 | #[serde(rename = "fileURL")] 444 | pub file_url: String, 445 | #[serde(rename = "previewURL")] 446 | pub preview_url: String, 447 | } 448 | 449 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 450 | #[serde(rename_all = "camelCase")] 451 | pub struct Markdown { 452 | pub content: String, 453 | } 454 | 455 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 456 | #[serde(rename_all = "camelCase")] 457 | pub struct Ask { 458 | #[serde(rename = "askId")] 459 | pub ask_id: AskId, 460 | pub anon: bool, 461 | #[serde(rename = "askingProject")] 462 | pub asking_project: Option, 463 | pub content: String, 464 | #[serde(rename = "sentAt")] 465 | pub sent_at: chrono::DateTime, 466 | } 467 | 468 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 469 | #[serde(rename_all = "camelCase")] 470 | pub struct PostResponse { 471 | pub post_id: PostId, 472 | } 473 | } 474 | 475 | #[test] 476 | fn test_parse_project_post_page() -> Result<(), Box> { 477 | let post_page: de::PostPage = 478 | serde_json::from_str(include_str!("../samples/example.project.posts.json"))?; 479 | assert_eq!(post_page.n_items, 3); 480 | assert_eq!( 481 | usize::try_from(post_page.n_items).unwrap(), 482 | post_page.items.len() 483 | ); 484 | let post = post_page 485 | .items 486 | .iter() 487 | .find(|post| post.post_id.0 == 185_838) 488 | .expect("Couldn't find post by ID 185838 as expected; did you change the sample?"); 489 | assert_eq!(post.headline, "This is a test post."); 490 | assert_eq!(post.filename, "185838-this-is-a-test-post"); 491 | assert_eq!(post.state, 1); 492 | //assert!(post.transparent_share_of_post_id.is_none()); 493 | assert_eq!(post.num_comments, 0); 494 | assert_eq!(post.num_shared_comments, 0); 495 | assert_eq!(post.tags.len(), 3); 496 | assert_eq!(post.cws.len(), 0); 497 | assert_eq!(post.related_projects.len(), 0); 498 | 499 | let post = post_page 500 | .items 501 | .iter() 502 | .find(|post| post.post_id.0 == 185_916) 503 | .expect("Couldn't find post by ID 185916 as expected; did you change the sample?"); 504 | 505 | assert_eq!(post.related_projects.len(), 2); 506 | assert_eq!(post.share_tree.len(), 1); 507 | Ok(()) 508 | } 509 | 510 | #[test] 511 | fn test_parse_project_post_page_with_ask() -> Result<(), Box> { 512 | let post_page: de::PostPage = 513 | serde_json::from_str(include_str!("../samples/with-ask.project.posts.json"))?; 514 | 515 | let post = post_page 516 | .items 517 | .iter() 518 | .find(|post| post.post_id.0 == 1_811_182) 519 | .expect("Couldn't find post by ID 1811182 as expected; did you change the sample?"); 520 | 521 | assert_eq!(post.blocks.len(), 3); 522 | let ask = match post.blocks.first() { 523 | Some(de::Block::Ask { ask }) => ask, 524 | _ => panic!("no ask block in ask test post"), 525 | }; 526 | assert_eq!(ask.ask_id, crate::ask::AskId("871936863978390842".into())); 527 | assert!(!ask.anon); 528 | assert!(ask.asking_project.is_some()); 529 | assert_eq!(ask.content.len(), 84); 530 | Ok(()) 531 | } 532 | 533 | #[test] 534 | fn test_convert_post() -> Result<(), Box> { 535 | let post_page: de::PostPage = 536 | serde_json::from_str(include_str!("../samples/example.project.posts.json"))?; 537 | let post = post_page 538 | .items 539 | .iter() 540 | .find(|post| post.post_id.0 == 185_838) 541 | .expect("Couldn't find post by ID 185838 as expected; did you change the sample?"); 542 | let converted_post = Post::from(post.clone()); 543 | let converted_post_metadata = converted_post 544 | .metadata 545 | .expect("No metadata for converted post!"); 546 | assert_eq!(post.post_id, converted_post_metadata.locations.id); 547 | assert!(!converted_post.attachments.is_empty()); 548 | assert_eq!( 549 | converted_post_metadata.publication_date.timestamp(), 550 | 1_667_531_869 551 | ); 552 | 553 | Ok(()) 554 | } 555 | 556 | #[test] 557 | fn test_convert_project_post_with_ask() -> Result<(), Box> { 558 | let post_page: de::PostPage = 559 | serde_json::from_str(include_str!("../samples/with-ask.project.posts.json"))?; 560 | 561 | let post = post_page 562 | .items 563 | .iter() 564 | .find(|post| post.post_id.0 == 1_811_182) 565 | .expect("Couldn't find post by ID 1811182 as expected; did you change the sample?"); 566 | 567 | let converted_post = Post::from(post.clone()); 568 | let ask = converted_post.ask.expect("no ask in ask example post!"); 569 | assert_eq!(ask.id(), "871936863978390842"); 570 | assert_eq!(ask.content.len(), 84); 571 | let asker = ask.asker.expect("no asker in ask example post!"); 572 | assert_eq!(asker.handle, "asunchaser".to_string()); 573 | Ok(()) 574 | } 575 | -------------------------------------------------------------------------------- /src/session.rs: -------------------------------------------------------------------------------- 1 | use crate::{Client, Error, Post, PostId}; 2 | use reqwest::Method; 3 | 4 | /// Logged-in session. 5 | #[derive(Debug, Clone)] 6 | pub struct Session { 7 | pub(crate) client: Client, 8 | } 9 | 10 | impl Session { 11 | /// Returns the inner [`Client`] for this session. 12 | /// 13 | /// This can be used to access methods present on `Client`. 14 | #[must_use] 15 | pub fn as_client(&self) -> &Client { 16 | &self.client 17 | } 18 | 19 | /// Logs into cohost with an email and password, returning a `Session`. 20 | /// 21 | /// Securely storing the user's password is an exercise left to the caller. 22 | pub async fn login(email: &str, password: &str) -> Result { 23 | Client::new().login(email, password).await 24 | } 25 | 26 | /// Create a post. 27 | /// 28 | /// Returns the new post's ID. 29 | #[tracing::instrument(skip(self))] 30 | pub async fn create_post(&self, page: &str, post: &mut Post) -> Result { 31 | post.send( 32 | self, 33 | Method::POST, 34 | &format!("project/{}/posts", page), 35 | page, 36 | None, 37 | ) 38 | .await 39 | } 40 | 41 | /// Share a post. 42 | /// 43 | /// Returns the new post's ID. 44 | /// 45 | /// To share a post with no additional content, use `Post::default()` for `post`. 46 | #[tracing::instrument(skip(self))] 47 | pub async fn share_post( 48 | &self, 49 | page: &str, 50 | shared_post: PostId, 51 | post: &mut Post, 52 | ) -> Result { 53 | post.send( 54 | self, 55 | Method::POST, 56 | &format!("project/{}/posts", page), 57 | page, 58 | Some(shared_post), 59 | ) 60 | .await 61 | } 62 | 63 | /// Edit a post. 64 | /// 65 | /// Returns the edited post's ID. 66 | #[tracing::instrument(skip(self))] 67 | pub async fn edit_post( 68 | &self, 69 | page: &str, 70 | id: PostId, 71 | post: &mut Post, 72 | ) -> Result { 73 | post.send( 74 | self, 75 | Method::PUT, 76 | &format!("project/{}/posts/{}", page, id), 77 | page, 78 | None, 79 | ) 80 | .await 81 | } 82 | 83 | /// Delete a post. 84 | #[tracing::instrument(skip(self))] 85 | pub async fn delete_post(&self, page: &str, id: PostId) -> Result<(), Error> { 86 | self.client 87 | .delete(&format!("project/{}/posts/{}", page, id)) 88 | .send() 89 | .await? 90 | .error_for_status()?; 91 | Ok(()) 92 | } 93 | } 94 | --------------------------------------------------------------------------------