cohost.js-fork/lib.js

330 lines
7.9 KiB
JavaScript

const _fetch = require("node-fetch");
const crypto = require("crypto");
const needle = require("needle");
const { decode } = require("./lib/b64arraybuffer");
const fs = require("fs");
const path = require("path");
const mime = require("mime-types");
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,
headers = {}
) {
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));
}
/**
* Get Notifications of the User. Docs TBD
*/
async getNotifications(offset = 0, limit = 20) {
return await fetch("GET", "/notifications/list", this.sessionCookie, {
offset,
limit
});
}
}
/**
* Represents a cohost Project (e.g. @mog)
*/
class Project {
constructor(user, data) {
this.user = user;
this.populate(data);
}
/**
* Creates a Project. Docs TBD
*/
static async create(user, data) {
return await fetch("POST", "/project", user.sessionCookie, 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));
}
async uploadAttachment(postId, filename) {
const fileContentType = mime.lookup(filename);
const fileContentLength = fs.statSync(filename).size;
const S3Parameters = await fetch(
"POST",
`/project/${encodeURIComponent(
this.handle
)}/posts/${postId}/attach/start`,
this.user.sessionCookie,
{
filename: path.basename(filename),
content_type: fileContentType,
content_length: fileContentLength
}
);
await needle(
"post",
S3Parameters.url,
{
...S3Parameters.requiredFields,
file: {
file: filename,
content_type: fileContentType
}
},
{ multipart: true }
);
const res = fetch(
"POST",
`/project/${encodeURIComponent(
this.handle
)}/posts/${postId}/attach/finish/${S3Parameters.attachmentId}`,
this.user.sessionCookie
);
return await res;
}
}
/**
* 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
* @property {string} altText
*/
/**
* @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
* @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 postId;
}
/** see {@link create} */
static async update(project, postId, data) {
await fetch(
"PUT",
`/project/${encodeURIComponent(project.handle)}/posts/${postId}`,
project.user.sessionCookie,
data
);
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
};