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, };