diff --git a/src/Consts.ts b/src/Consts.ts index 2eb0adf..cec749d 100644 --- a/src/Consts.ts +++ b/src/Consts.ts @@ -44,12 +44,12 @@ interface SessionJSON { }; } -interface Session { +type Session = { csrfToken: string; token: string; cookieSet: string; sessionJSON: SessionJSON; -} +} | undefined; const UserAgent = "Mozilla/5.0"; diff --git a/src/ScratchSession.ts b/src/ScratchSession.ts index 211bfe2..da64b77 100644 --- a/src/ScratchSession.ts +++ b/src/ScratchSession.ts @@ -59,11 +59,13 @@ type Message = PartialMessage & ({ * Manages a Scratch session. */ class ScratchSession { - username: string; - csrfToken: string; - token: string; - cookieSet: string; - sessionJSON: SessionJSON; + auth?: { + username: string; + csrfToken: string; + token: string; + cookieSet: string; + sessionJSON: SessionJSON; + } /** * Sets up the ScratchSession to use authenticated functions. @@ -71,7 +73,6 @@ class ScratchSession { * @param pass The password of the user you want to log in to. */ async init(user: string, pass: string) { - this.username = user; // a lot of this code is taken from // https://github.com/CubeyTheCube/scratchclient/blob/main/scratchclient/ScratchSession.py // thanks! @@ -94,20 +95,22 @@ class ScratchSession { throw new Error("Login failed."); } - this.csrfToken = /scratchcsrftoken=(.*?);/gm.exec( - loginReq.headers.get("set-cookie") - )[1]; - this.token = /"(.*)"/gm.exec(loginReq.headers.get("set-cookie"))[1]; - this.cookieSet = + const setCookie = loginReq.headers.get("set-cookie"); + if(!setCookie) throw Error("Something went wrong"); + const csrfToken = /scratchcsrftoken=(.*?);/gm.exec( + setCookie + )![1]; + const token = /"(.*)"/gm.exec(setCookie)![1]; + const cookieSet = "scratchcsrftoken=" + - this.csrfToken + + csrfToken + ";scratchlanguage=en;scratchsessionsid=" + - this.token + + token + ";"; const sessionFetch = await fetch("https://scratch.mit.edu/session", { method: "GET", headers: { - Cookie: this.cookieSet, + Cookie: cookieSet, "User-Agent": UserAgent, Referer: "https://scratch.mit.edu/", Host: "scratch.mit.edu", @@ -118,7 +121,14 @@ class ScratchSession { "Accept-Encoding": "gzip, deflate, br" } }); - this.sessionJSON = (await sessionFetch.json()) as SessionJSON; + const sessionJSON = (await sessionFetch.json()) as SessionJSON; + this.auth = { + username: user, + csrfToken, + token, + cookieSet, + sessionJSON + } } /** @@ -131,6 +141,7 @@ class ScratchSession { * await session.uploadToAssets(fs.readFileSync("photo.png"), "png"); // returns URL to image */ async uploadToAssets(buffer: Buffer, fileExtension: string) { + if(!this.auth) throw Error("You must be logged in to use this"); const md5hash = createHash("md5").update(buffer).digest("hex"); const upload = await fetch( `https://assets.scratch.mit.edu/${md5hash}.${fileExtension}`, @@ -138,7 +149,7 @@ class ScratchSession { method: "POST", body: buffer, headers: { - Cookie: this.cookieSet, + Cookie: this.auth.cookieSet, "User-Agent": UserAgent, Referer: "https://scratch.mit.edu/", Host: "assets.scratch.mit.edu", @@ -210,11 +221,12 @@ class ScratchSession { * @param offset The offset of messages */ async getMessages(limit: number = 40, offset: number = 0) { - const request = await fetch(`https://api.scratch.mit.edu/users/${this.username}/messages?limit=${limit}&offset=${offset}`, { + if(!this.auth) throw Error("You must be logged in to use this"); + const request = await fetch(`https://api.scratch.mit.edu/users/${this.auth.username}/messages?limit=${limit}&offset=${offset}`, { headers: { Origin: "https://scratch.mit.edu", Referer: "https://scratch.mit.edu/", - "X-Token": this.sessionJSON.user.token + "X-Token": this.auth.sessionJSON.user.token } }); if(!request.ok) throw Error(`Request failed with status ${request.status}`); @@ -225,14 +237,14 @@ class ScratchSession { * Logs out of Scratch. */ async logout() { - if (!this.csrfToken || !this.token) return; + if(!this.auth) throw Error("You must be logged in to use this"); const logoutFetch = await fetch( "https://scratch.mit.edu/accounts/logout/", { method: "POST", - body: `csrfmiddlewaretoken=${this.csrfToken}`, + body: `csrfmiddlewaretoken=${this.auth.csrfToken}`, headers: { - Cookie: this.cookieSet, + Cookie: this.auth.cookieSet, "User-Agent": UserAgent, accept: "application/json", Referer: "https://scratch.mit.edu/", @@ -246,7 +258,8 @@ class ScratchSession { ); if (!logoutFetch.ok) { throw new Error(`Error in logging out. ${logoutFetch.status}`); - } + }; + this.auth = undefined; } } diff --git a/src/classes/CloudConnection.ts b/src/classes/CloudConnection.ts index 893aa19..fc1a751 100644 --- a/src/classes/CloudConnection.ts +++ b/src/classes/CloudConnection.ts @@ -13,8 +13,7 @@ import events from "node:events"; class CloudConnection extends events.EventEmitter { id: number; session: Session; - server: string; - connection: WebSocket; + connection!: WebSocket; open: boolean = false; queue: Array<{ user: string; @@ -29,11 +28,11 @@ class CloudConnection extends events.EventEmitter { super(); this.id = id; this.session = session; - this.connect(); } private connect() { + if(!this.session) throw Error("You need to be logged in") this.open = false; this.connection = new WebSocket("wss://clouddata.scratch.mit.edu", { headers: { @@ -52,6 +51,7 @@ class CloudConnection extends events.EventEmitter { } }); this.connection.on("open", () => { + if(!this.session) throw Error("You need to be logged in") this.open = true; this.send({ method: "handshake", @@ -90,6 +90,7 @@ class CloudConnection extends events.EventEmitter { * @param value The value to set the variable to. */ setVariable(variable: string, value: number | string) { + if(!this.session) throw Error("You need to be logged in") const varname = variable.startsWith("☁ ") ? variable.substring(2) : variable; @@ -116,9 +117,9 @@ class CloudConnection extends events.EventEmitter { /** * Gets a cloud variable. * @param variable The variable name to get. - * @returns {string} The value of the variable in string format. + * @returns The value of the variable in string format if it exists. */ - getVariable(variable: string): string { + getVariable(variable: string) { const varname = variable.startsWith("☁ ") ? variable.substring(2) : variable; diff --git a/src/classes/Profile.ts b/src/classes/Profile.ts index 26f1299..76b002b 100644 --- a/src/classes/Profile.ts +++ b/src/classes/Profile.ts @@ -1,5 +1,6 @@ import { Session, UserAgent } from "../Consts"; import { parse } from "node-html-parser"; +import ScratchSession from "../ScratchSession"; interface UserAPIResponse { id: number; @@ -46,9 +47,9 @@ interface ProfileComment { class Profile { user: string; session: Session; - constructor(session: Session, username: string) { + constructor(session: ScratchSession, username: string) { this.user = username; - this.session = session; + this.session = session.auth; } /** @@ -58,7 +59,7 @@ class Profile { */ async getStatus() { const dom = parse(await this.getUserHTML()); - return dom.querySelector(".group").innerHTML.trim() as + return dom.querySelector(".group")!.innerHTML.trim() as | "Scratcher" | "New Scratcher" | "Scratch Team"; @@ -68,6 +69,7 @@ class Profile { * Follow the user */ async follow() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://scratch.mit.edu/site-api/users/followers/${this.user}/add/?usernames=${this.session.sessionJSON.user.username}`, { @@ -92,6 +94,7 @@ class Profile { * Unfollow the user */ async unfollow() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://scratch.mit.edu/site-api/users/followers/${this.user}/remove/?usernames=${this.session.sessionJSON.user.username}`, { @@ -117,6 +120,7 @@ class Profile { * @param id The comment ID, for example 12345, *not* comment-12345. */ async deleteComment(id: string | number) { + if(!this.session) throw Error("You need to be logged in") const delFetch = await fetch( `https://scratch.mit.edu/site-api/comments/user/${this.user}/del/`, { @@ -199,38 +203,38 @@ class Profile { for (let elID in items) { const element = items[elID]; if (typeof element == "function") break; - const commentID = element.querySelector(".comment").id; + const commentID = element.querySelector(".comment")!.id; const commentPoster = element - .querySelector(".comment") + .querySelector(".comment")! .getElementsByTagName("a")[0] - .getAttribute("data-comment-user"); + .getAttribute("data-comment-user")!; const commentContent = element - .querySelector(".comment") - .querySelector(".info") - .querySelector(".content") + .querySelector(".comment")! + .querySelector(".info")! + .querySelector(".content")! .innerHTML.trim(); // get replies let replies: ProfileCommentReply[] = []; let replyList = element - .querySelector(".replies") + .querySelector(".replies")! .querySelectorAll(".reply"); for (let replyID in replyList) { const reply = replyList[replyID]; if (reply.tagName === "A") continue; if (typeof reply === "function") continue; if (typeof reply === "number") continue; - const commentID = reply.querySelector(".comment").id; + const commentID = reply.querySelector(".comment")!.id; const commentPoster = reply - .querySelector(".comment") + .querySelector(".comment")! .getElementsByTagName("a")[0] - .getAttribute("data-comment-user"); + .getAttribute("data-comment-user")!; // regex here developed at https://scratch.mit.edu/discuss/post/5983094/ const commentContent = reply - .querySelector(".comment") - .querySelector(".info") - .querySelector(".content") + .querySelector(".comment")! + .querySelector(".info")! + .querySelector(".content")! .textContent.trim() .replace(/\n+/gm, "") .replace(/\s+/gm, " "); @@ -260,6 +264,7 @@ class Profile { * Toggle the comments section on the profile */ async toggleComments() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch(`https://scratch.mit.edu/site-api/comments/user/${this.user}/toggle-comments/`, { method: "POST", headers: { diff --git a/src/classes/Project.ts b/src/classes/Project.ts index 8b0217e..b913daa 100644 --- a/src/classes/Project.ts +++ b/src/classes/Project.ts @@ -173,6 +173,7 @@ class Project { * @param commentee_id The ID of the user to ping in the starting */ async comment(content: string, parent_id?: number, commentee_id?: number) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/proxy/comments/project/${this.id}`, { @@ -207,6 +208,7 @@ class Project { } async setCommentsAllowed(state: boolean) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch(`https://api.scratch.mit.edu/projects/${this.id}`, { method: "PUT", body: JSON.stringify({ @@ -227,6 +229,7 @@ class Project { * @param value The value you want to set the title to. */ async setTitle(value: string) { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://api.scratch.mit.edu/projects/${this.id}`, { @@ -254,6 +257,7 @@ class Project { * @param value The value you want to set the instructions to. */ async setInstructions(value: string) { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://api.scratch.mit.edu/projects/${this.id}`, { @@ -282,6 +286,7 @@ class Project { * @param value The value you want to set the Notes and Credits to. */ async setNotesAndCredits(value: string) { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://api.scratch.mit.edu/projects/${this.id}`, { @@ -310,6 +315,7 @@ class Project { * @param buffer The buffer of the thumbnail image file */ async setThumbnail(buffer: Buffer) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://scratch.mit.edu/internalapi/project/thumbnail/${this.id}/set/`, { @@ -331,6 +337,7 @@ class Project { * Unshares the project (requires ownership of the project). */ async unshare() { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://scratch.mit.edu/site-api/projects/all/${this.id}/`, { @@ -359,6 +366,7 @@ class Project { * Check if the user is loving the project */ async isLoving() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/projects/${this.id}/loves/user/${this.session.sessionJSON.user.username}`, { @@ -382,6 +390,7 @@ class Project { * Check if the user is favoriting the project */ async isFavoriting() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/projects/${this.id}/favorites/user/${this.session.sessionJSON.user.username}`, { @@ -407,6 +416,7 @@ class Project { * @param loving Either true or false */ async setLoving(loving: boolean) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/proxy/projects/${this.id}/loves/user/${this.session.sessionJSON.user.username}`, { @@ -439,6 +449,7 @@ class Project { * @param favoriting Either true or false */ async setFavoriting(favoriting: boolean) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/proxy/projects/${this.id}/favorites/user/${this.session.sessionJSON.user.username}`, { @@ -469,6 +480,7 @@ class Project { * Shares the project (requires ownership of the project). */ async share() { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://api.scratch.mit.edu/proxy/projects/${this.id}/share/`, { diff --git a/src/classes/Studio.ts b/src/classes/Studio.ts index 07e0542..83eb2ad 100644 --- a/src/classes/Studio.ts +++ b/src/classes/Studio.ts @@ -70,6 +70,7 @@ class Studio { * Follow the studio */ async follow() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://scratch.mit.edu/site-api/users/bookmarkers/${this.id}/add/?usernames=${this.session.sessionJSON.user.username}`, { @@ -94,6 +95,7 @@ class Studio { * Unfollow the studio */ async unfollow() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://scratch.mit.edu/site-api/users/bookmarkers/${this.id}/remove/?usernames=${this.session.sessionJSON.user.username}`, { @@ -119,6 +121,7 @@ class Studio { * @param value The value to set the title to. */ async setTitle(value: string) { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://scratch.mit.edu/site-api/galleries/all/${this.id}/`, { @@ -143,6 +146,7 @@ class Studio { * @param value The value to set the description to. */ async setDescription(value: string) { + if(!this.session) throw Error("You need to be logged in") const setFetch = await fetch( `https://scratch.mit.edu/site-api/galleries/all/${this.id}/`, { @@ -167,6 +171,7 @@ class Studio { * @param username The username of the user to add. */ async inviteCurator(username: string) { + if(!this.session) throw Error("You need to be logged in") const inviteFetch = await fetch( `https://scratch.mit.edu/site-api/users/curators-in/${this.id}/invite_curator/?usernames=${username}`, { @@ -190,6 +195,7 @@ class Studio { * @param username The username of the user to remove. */ async removeCurator(username: string) { + if(!this.session) throw Error("You need to be logged in") const removeFetch = await fetch( `https://scratch.mit.edu/site-api/users/curators-in/${this.id}/remove/?usernames=${username}`, { @@ -213,6 +219,7 @@ class Studio { * @param project The project ID to add to the studio. */ async addProject(project: number) { + if(!this.session) throw Error("You need to be logged in") const addFetch = await fetch( `https://api.scratch.mit.edu/studios/${this.id}/project/${project}`, { @@ -233,6 +240,7 @@ class Studio { * @param project The project ID to remove from the studio. */ async removeProject(project: number) { + if(!this.session) throw Error("You need to be logged in") const removeFetch = await fetch( `https://api.scratch.mit.edu/studios/${this.id}/project/${project}`, { @@ -255,6 +263,7 @@ class Studio { * @param commentee_id The ID of the user to ping in the starting */ async comment(content: string, parent_id?: number, commentee_id?: number) { + if(!this.session) throw Error("You need to be logged in") const request = await fetch( `https://api.scratch.mit.edu/proxy/comments/studio/${this.id}`, { @@ -292,6 +301,7 @@ class Studio { * Toggle comments on or off */ async toggleComments() { + if(!this.session) throw Error("You need to be logged in") const request = await fetch(`https://scratch.mit.edu/site-api/comments/gallery/${this.id}/toggle-comments/`, { method: "POST", headers: { diff --git a/src/classes/forums/Forum.ts b/src/classes/forums/Forum.ts index 1635aa0..3b85ea5 100644 --- a/src/classes/forums/Forum.ts +++ b/src/classes/forums/Forum.ts @@ -37,17 +37,17 @@ class Forum { ); } const dom = parse(await res.text()); - const listDOMElement = dom.querySelector(".topic.list"); + const listDOMElement = dom.querySelector(".topic.list")!; const children = listDOMElement.getElementsByTagName("li"); children.forEach((child) => { const id = child .getElementsByTagName("a")[0] - .getAttribute("href") + .getAttribute("href")! .split("/") .splice(1)[3]; - const title = child.querySelector("strong").innerText; + const title = child.querySelector("strong")!.innerText; const replyCount = Number( - child.querySelector(".item span").innerText.split(" ")[0] + child.querySelector(".item span")!.innerText.split(" ")[0] ); const isSticky = child.classList.contains("sticky"); const topic = new Topic({ @@ -69,6 +69,7 @@ class Forum { * @param body The body of the topic */ async createTopic(title: string, body: string) { + if(!this.session) throw Error("You need to be logged in"); if (!this.id) throw Error("You need to add a forum id"); const form = new FormData(); form.append("csrfmiddlewaretoken", this.session.csrfToken); @@ -107,6 +108,7 @@ class Forum { * @param content The content to set the signature to */ async setSignature(content: string) { + if(!this.session) throw Error("You need to be logged in") const editFetch = await fetch( `https://scratch.mit.edu/discuss/settings/${this.session.sessionJSON.user.username}/`, { diff --git a/src/classes/forums/Post.ts b/src/classes/forums/Post.ts index 54d3a20..afa3793 100644 --- a/src/classes/forums/Post.ts +++ b/src/classes/forums/Post.ts @@ -46,6 +46,7 @@ class Post { * @param content The new content of the post */ async edit(content: string) { + if(!this.session) throw Error("You need to be logged in") const editFetch = await fetch( `https://scratch.mit.edu/discuss/post/${this.id}/edit/`, { diff --git a/src/classes/forums/Topic.ts b/src/classes/forums/Topic.ts index 9f43e2c..0e14ccc 100644 --- a/src/classes/forums/Topic.ts +++ b/src/classes/forums/Topic.ts @@ -37,7 +37,7 @@ class Topic { * @returns An array of posts in the topic. */ async getPosts(page: number = 1) { - let posts = []; + let posts: Post[] = []; const res = await fetch( `https://scratch.mit.edu/discuss/m/topic/${this.id}/?page=${page}`, @@ -54,14 +54,14 @@ class Topic { } const dom = parse(await res.text()); const children = dom - .querySelector(".content") + .querySelector(".content")! .getElementsByTagName("article"); children.forEach((child) => { - const id = child.getAttribute("id").split("-")[1]; - const content = child.querySelector(".post-content").innerHTML; - const parsableContent = child.querySelector(".post-content"); + const id = child.getAttribute("id")!.split("-")[1]; + const content = child.querySelector(".post-content")!.innerHTML; + const parsableContent = child.querySelector(".post-content")!; const time = new Date( - child.querySelector("time").getAttribute("datetime") + child.querySelector("time")!.getAttribute("datetime")! ); const post = new Post({ id: Number(id), @@ -84,6 +84,7 @@ class Topic { * @param body The body of the post */ async reply(body: string) { + if(!this.session) throw Error("You need to be logged in") const form = new FormData(); form.append("csrfmiddlewaretoken", this.session.csrfToken); form.append("body", body); @@ -119,6 +120,7 @@ class Topic { * Follows the topic. */ async follow() { + if(!this.session) throw Error("You need to be logged in") const followFetch = await fetch( `https://scratch.mit.edu/discuss/subscription/topic/${this.id}/add/`, { @@ -148,6 +150,7 @@ class Topic { * Unfollows the topic. */ async unfollow() { + if(!this.session) throw Error("You need to be logged in") const unfollowFetch = await fetch( `https://scratch.mit.edu/discuss/subscription/topic/${this.id}/delete/`, { diff --git a/src/utils.ts b/src/utils.ts index 04ddfc4..bb50c08 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import { Readable } from "node:stream"; // https://stackoverflow.com/a/49428486/17333186 export function streamToString(stream: Readable): Promise { - let chunks = []; + let chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); stream.on("error", (err) => reject(err)); diff --git a/tsconfig.json b/tsconfig.json index 528b04b..862bebf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "module": "es2022", - "target": "es2020" + "target": "es2020", + "strict": true } }