Initial commit

main
Móricz Gergő 2022-06-29 14:24:33 +02:00
parent 3f48a51643
commit c0063a38ae
6 changed files with 470 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
/notepad.js

56
README.md Normal file
View File

@ -0,0 +1,56 @@
# cohost.js
Unofficial API for cohost.org
## Install
```bash
npm i cohost
```
## Usage
```js
const cohost = require("cohost");
(async function() {
// Create User and authenticate
let user = new cohost.User();
await user.login("YOUR_EMAIL", "YOUR_PASSWORD");
// Get first Project of user
let [ project ] = await user.getProjects();
// Create Post
await cohost.Post.create(project, {
postState: 1,
headline: "hello world from cohost.js",
adultContent: false,
blocks: [],
cws: [],
tags: [],
});
// Get Posts of Project
let posts = await project.getPosts();
})();
```
## Features
Works:
* Logging in
* Getting the posts of a project
* Creating a post
Doesn't work:
* Editing a post: possible, haven't done it
* Sharing a post: possible, haven't done it
* Liking a post: possible, haven't done it
* Getting notifications: possible, haven't done it
* Uploading attachments: possible, haven't done it
* Getting the home feed: possible, haven't done it
* Editing profiles: possible, haven't done it
* Getting followers and following: possible, haven't done it
* Getting bookmarks and bookmarking: possible, haven't done it
* Getting a post by its ID: **seems impossible? endpoint seems to be disabled**
* Getting posts from a tag: haven''t checked
* ...everything else

244
lib.js Normal file
View File

@ -0,0 +1,244 @@
const _fetch = require("node-fetch");
const crypto = require("crypto");
const { decode } = require("./lib/b64arraybuffer");
const API_BASE = "https://cohost.org/api/v1";
/**
* Fetches an API endpoint.
*
* @private
* @param {string} method HTTP method to use
* @param {string} endpoint Relative endpoint to fetch
* @param {string} [cookies] Cookies to send. Used for auth
* @param {object} [data] Data to send. Query if method is GET, body if method is anything else
* @param {boolean} [complex=false] Whether to return {headers, body}, or just the body
* @returns Response, JSON parsed if parsable, string if not
*/
async function fetch(method, endpoint, cookies = "", data, complex = false) {
let url = API_BASE + endpoint + (method == "GET" && data ? "?" + new URLSearchParams(data).toString() : "");
let req = await _fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"Cookie": cookies
},
body: (method != "GET" && data) ? JSON.stringify(data) : undefined
});
let res = await req.text();
try {
res = JSON.parse(res);
} catch (_) {}
if (req.status >= 400) {
throw JSON.stringify(res);
} else {
if (complex) {
return {
headers: req.headers,
body: res
};
} else {
return res;
}
}
}
/**
* Represents a cohost User (e.g. john.doe@gmail.com)
*/
class User {
/**
* Authenticates the User.
* This should always be called before using this instance or its references.
*
* @param {string} email E-mail address
* @param {string} password Password
*/
async login(email, password) {
const { salt } = await fetch(
"GET",
"/login/salt",
undefined,
{ email }
);
const hash = crypto.pbkdf2Sync(Buffer.from(password, "utf8"), decode(salt), 200000, 128, "sha384")
const clientHash = Buffer.from(hash).toString("base64");
const res = await fetch(
"POST",
"/login",
undefined,
{ email, clientHash },
true
);
this.sessionCookie = res.headers.get("set-cookie").split(";")[0];
this.userId = res.body.userId;
this.email = res.body.email;
}
/**
* Get Projects the User has edit permissions on.
*
* @returns {Project[]} User's projects
*/
async getProjects() {
return (await fetch(
"GET",
"/projects/edited",
this.sessionCookie
)).projects.map(x => new Project(this, x));
}
}
/**
* Represents a cohost Project (e.g. @mog)
*/
class Project {
constructor(user, data) {
this.user = user;
this.populate(data);
}
/**
* @private
*/
populate(data) {
this.id = data.projectId;
this.handle = data.handle;
this.displayName = data.displayName;
this.dek = data.dek;
this.description = data.description;
this.avatarURL = data.avatarURL;
this.headerURL = data.headerURL;
this.privacy = data.privacy;
this.pronouns = data.pronouns;
this.url = data.url;
this.flags = data.flags;
this.avatarShape = data.avatarShape;
}
/**
* @param {number} [page=0] Page of posts to get, 20 posts per page
* @returns {object[]}
*/
async getPosts(page = 0) {
let res = await fetch(
"GET",
`/project/${encodeURIComponent(this.handle)}/posts?page=${encodeURIComponent(page.toString())}`,
this.user.sessionCookie
);
return res.items.map(x => new Post(this.user, x));
}
}
/**
* Represents a cohost Post
*/
class Post {
constructor(user, data) {
this.user = user;
this.populate(data);
}
/**
* @typedef {Object} PostMarkdownBlock
* @property {string} content
*/
/**
* @typedef {Object} PostAttachmentBlock
* @property {string} fileURL
* @property {string} attachmentId
*/
/**
* @typedef {Object} PostBlock
* @property {string} type Type of block. Currently available: 'markdown' and 'attachment'
* @property {PostMarkdownBlock} [markdown] Should only be present if type is 'markdown'
* @property {PostAttachmentBlock} [attachment] Should only be present if type is 'attachment'
*/
/**
* @typedef {Object} PostCreate
* @property {number} postState 1 for published, 0 for draft? or pending maybe?
* @property {string} headline Headline
* @property {boolean} adultContent Does the post contain adult content?
* @property {PostBlock[]} blocks Blocks (docs TBD)
* @property {string[]} cws Content Warnings
* @property {string[]} tags Tags (docs TBD)
*/
/**
*
* @param {Project} project Project to post to
* @param {PostCreate} data
* @returns
*/
static async create(project, data) {
let { postId } = await fetch(
"POST",
`/project/${encodeURIComponent(project.handle)}/posts`,
project.user.sessionCookie,
data
);
// return await Post.getById(project, postId);
return postId;
}
// Endpoint is disabled;
// static async getById(project, id) {
// let data = await fetch(
// "GET",
// `/project_posts/${id}`,
// project.user.sessionCookie
// );
// return new Post(user, data);
// }
/**
* @private
*/
populate(data) {
this.id = data.postId;
this.headline = data.headline;
this.publishedAt = new Date(data.publishedAt);
this.filename = data.filename;
this.transparentShareOfPostId = data.transparentShareOfPostId;
this.state = data.state;
this.numComments = data.numComments;
this.numSharedComments = data.numSharedComments;
this.cws = data.cws;
this.tags = data.tags;
this.blocks = data.blocks;
this.plainTextBody = data.plainTextBody;
this.project = new Project(this.user, data.postingProject);
this.shareTree = data.shareTree;
this.relatedProjects = data.relatedProjects;
this.effectiveAdultContent = data.effectiveAdultContent;
this.isEditor = data.isEditor;
this.contributorBlockIncomingOrOutgoing = data.contributorBlockIncomingOrOutgoing;
this.hasAnyContributorMuted = data.hasAnyContributorMuted;
this.isLiked = data.isLiked;
this.canShare = data.canShare;
this.canPublish = data.canPublish;
this.singlePostPageUrl = data.singlePostPageUrl;
this.renderInIframe = data.renderInIframe;
this.postPreviewIFrameUrl = data.postPreviewIFrameUrl;
this.postEditUrl = data.postEditUrl;
}
}
module.exports = {
User,
Project,
Post,
};

58
lib/b64arraybuffer.js Normal file
View File

@ -0,0 +1,58 @@
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Use a lookup table to find the index.
var lookup = new Uint8Array(256);
for (var i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
function encode(arraybuffer) {
var bytes = new Uint8Array(arraybuffer),
i, len = bytes.length, base64 = "";
for (i = 0; i < len; i+=3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1) + "=";
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + "==";
}
return base64;
};
function decode(base64) {
var bufferLength = base64.length * 0.75,
len = base64.length, i, p = 0,
encoded1, encoded2, encoded3, encoded4;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
var arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i+=4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i+1)];
encoded3 = lookup[base64.charCodeAt(i+2)];
encoded4 = lookup[base64.charCodeAt(i+3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};
module.exports = {encode, decode};

83
package-lock.json generated Normal file
View File

@ -0,0 +1,83 @@
{
"name": "cohost",
"version": "0.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cohost",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"node-fetch": "2"
}
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
},
"dependencies": {
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "cohost",
"version": "0.0.1",
"description": "Unofficial API for cohost.org",
"main": "lib.js",
"scripts": {},
"keywords": [
"cohost"
],
"author": "mogery",
"license": "MIT",
"dependencies": {
"node-fetch": "2"
},
"directories": {
"lib": "lib"
},
"devDependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/mogery/cohost.js.git"
},
"bugs": {
"url": "https://github.com/mogery/cohost.js/issues"
},
"homepage": "https://github.com/mogery/cohost.js#readme"
}