diff --git a/commons/src/index.ts b/commons/src/index.ts index adddb517..ab97f710 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -94,6 +94,8 @@ export * from './interfaces/websites/pixelfed/pixelfed.file.options.interface'; export * from './interfaces/websites/bluesky/bluesky.account.interface'; export * from './interfaces/websites/bluesky/bluesky.file.options.interface'; export * from './interfaces/websites/bluesky/bluesky.notification.options.interface'; +export * from './interfaces/websites/twitter/twitter.file.options.interface'; +export * from './interfaces/websites/twitter/twitter.account.interface'; // Models/Entities export * from './models/default-options.entity'; @@ -104,4 +106,4 @@ import * as WebsiteOptions from './websites/websites'; export { WebsiteOptions }; // Doesn't really fit :P -export * from './websites/megalodon/megalodon.instancesettings'; \ No newline at end of file +export * from './websites/megalodon/megalodon.instancesettings'; diff --git a/commons/src/interfaces/websites/twitter/twitter.account.interface.ts b/commons/src/interfaces/websites/twitter/twitter.account.interface.ts new file mode 100644 index 00000000..c15dc22e --- /dev/null +++ b/commons/src/interfaces/websites/twitter/twitter.account.interface.ts @@ -0,0 +1,8 @@ +export interface TwitterAccountData { + key: string; + secret: string; + user_id: number; + screen_name: string; + oauth_token: string; + oauth_token_secret: string; +} diff --git a/commons/src/websites/twitter/twitter.file.options.ts b/commons/src/websites/twitter/twitter.file.options.ts new file mode 100644 index 00000000..760ff5d0 --- /dev/null +++ b/commons/src/websites/twitter/twitter.file.options.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; +import { DefaultFileOptions } from '../../interfaces/submission/default-options.interface'; +import { TwitterFileOptions } from '../../interfaces/websites/twitter/twitter.file.options.interface'; +import { DefaultFileOptionsEntity } from '../../models/default-file-options.entity'; + +export class TwitterFileOptionsEntity + extends DefaultFileOptionsEntity + implements TwitterFileOptions +{ + @Expose() + @IsString() + contentBlur?: 'other' | 'graphic_violence' | 'adult_content'; + + constructor(entity?: Partial) { + super(entity as DefaultFileOptions); + } +} \ No newline at end of file diff --git a/commons/src/websites/twitter/twitter.options.ts b/commons/src/websites/twitter/twitter.options.ts new file mode 100644 index 00000000..2bc4f2e3 --- /dev/null +++ b/commons/src/websites/twitter/twitter.options.ts @@ -0,0 +1,7 @@ +import { DefaultOptionsEntity } from '../../models/default-options.entity'; +import { TwitterFileOptionsEntity } from './twitter.file.options'; + +export class Twitter { + static readonly FileOptions = TwitterFileOptionsEntity; + static readonly NotificationOptions = DefaultOptionsEntity; +} \ No newline at end of file diff --git a/commons/src/websites/websites.ts b/commons/src/websites/websites.ts index 73263191..52e4360e 100644 --- a/commons/src/websites/websites.ts +++ b/commons/src/websites/websites.ts @@ -29,3 +29,4 @@ export * from './weasyl/weasyl.options'; export * from './itaku/itaku.options'; export * from './pixelfed/pixelfed.options'; export * from './bluesky/bluesky.options'; +export * from './twitter/twitter.options'; diff --git a/electron-app/package-lock.json b/electron-app/package-lock.json index 4e7991e5..7275821a 100644 --- a/electron-app/package-lock.json +++ b/electron-app/package-lock.json @@ -1,12 +1,12 @@ { "name": "postybirb-plus", - "version": "3.1.35", + "version": "3.1.41", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "postybirb-plus", - "version": "3.1.35", + "version": "3.1.41", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -29,6 +29,7 @@ "electron-updater": "^5.2.1", "electron-window-state": "^5.0.3", "form-data": "^4.0.0", + "form-urlencoded": "^6.1.4", "fs-extra": "^8.1.0", "iconv-lite": "^0.5.1", "image-decode": "^1.2.2", @@ -50,6 +51,7 @@ "set-cookie-parser": "^2.4.5", "shortid": "^2.2.16", "turndown": "^5.0.3", + "twitter-lite": "^1.1.0", "uuid": "^3.3.3", "winston": "^3.2.1", "winston-daily-rotate-file": "^4.5.5" @@ -11947,6 +11949,14 @@ "moment-timezone": "^0.5.x" } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -14138,6 +14148,11 @@ "node": ">= 6" } }, + "node_modules/form-urlencoded": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.1.4.tgz", + "integrity": "sha512-vDFWgogZ04th20oxQtDSF8CDjrBgAPtP1V+W3kZni5IZqyasDDVNqCQGOXfYlmOJj0zpROgiAtPJXCmidtxDTQ==" + }, "node_modules/formidable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", @@ -21849,6 +21864,11 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==" }, + "node_modules/oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -26120,6 +26140,15 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, + "node_modules/twitter-lite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/twitter-lite/-/twitter-lite-1.1.0.tgz", + "integrity": "sha512-v37I0zyYXjn3/zdm/ii+1RiBFZ2SCB3wLl1N5nfW8TUqoNDYBR66oXBFbQavKEvjg5WqcTUzNhAhe6FkgylPsw==", + "dependencies": { + "cross-fetch": "^3.0.0", + "oauth-1.0a": "^2.2.4" + } + }, "node_modules/type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -36479,6 +36508,14 @@ "moment-timezone": "^0.5.x" } }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "requires": { + "node-fetch": "^2.6.12" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -38210,6 +38247,11 @@ "mime-types": "^2.1.12" } }, + "form-urlencoded": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-6.1.4.tgz", + "integrity": "sha512-vDFWgogZ04th20oxQtDSF8CDjrBgAPtP1V+W3kZni5IZqyasDDVNqCQGOXfYlmOJj0zpROgiAtPJXCmidtxDTQ==" + }, "formidable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", @@ -44342,6 +44384,11 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==" }, + "oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -47753,6 +47800,15 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, + "twitter-lite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/twitter-lite/-/twitter-lite-1.1.0.tgz", + "integrity": "sha512-v37I0zyYXjn3/zdm/ii+1RiBFZ2SCB3wLl1N5nfW8TUqoNDYBR66oXBFbQavKEvjg5WqcTUzNhAhe6FkgylPsw==", + "requires": { + "cross-fetch": "^3.0.0", + "oauth-1.0a": "^2.2.4" + } + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", diff --git a/electron-app/package.json b/electron-app/package.json index 9f057581..8b348939 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus", - "version": "3.1.40", + "version": "3.1.41", "description": "(ClientServer) PostyBirb is an application that helps artists post art and other multimedia to multiple websites more quickly.", "main": "dist/main.js", "author": "Michael DiCarlo", @@ -78,6 +78,7 @@ "set-cookie-parser": "^2.4.5", "shortid": "^2.2.16", "turndown": "^5.0.3", + "twitter-lite": "^1.1.0", "uuid": "^3.3.3", "winston": "^3.2.1", "winston-daily-rotate-file": "^4.5.5" diff --git a/electron-app/src/app/settings.ts b/electron-app/src/app/settings.ts index af38da13..54640fa7 100644 --- a/electron-app/src/app/settings.ts +++ b/electron-app/src/app/settings.ts @@ -1,5 +1,5 @@ /* tslint:disable: no-console no-var-requires */ -import { app } from 'electron'; +import { app, dialog } from 'electron'; import { Settings } from 'postybirb-commons'; const fs = require('fs-extra'); const path = require('path'); @@ -9,8 +9,7 @@ const util = require('./utils'); const settingsPath = path.join(global.BASE_DIRECTORY, 'data', 'settings.json'); fs.ensureFileSync(settingsPath); -const adapter = new FileSync(settingsPath); -const settings = low(adapter); +const settings = init(); const settingDefaults: Settings = { advertise: true, emptyQueueOnFailedPost: true, @@ -34,3 +33,19 @@ if (!settings.getState().useHardwareAcceleration || util.isLinux()) { } global.settingsDB = settings; + +function init() { + try { + const adapter = new FileSync(settingsPath); + return low(adapter); + } catch (e) { + console.error('Error initializing settings database', e); + fs.removeSync(settingsPath); + dialog.showErrorBox( + 'Settings were corrupted', + 'Settings could not be loaded and had to be reset.', + ); + const adapter = new FileSync(settingsPath); + return low(adapter); + } +} diff --git a/electron-app/src/server/websites/bluesky/bluesky.service.ts b/electron-app/src/server/websites/bluesky/bluesky.service.ts index 76c1db3e..770bfdd2 100644 --- a/electron-app/src/server/websites/bluesky/bluesky.service.ts +++ b/electron-app/src/server/websites/bluesky/bluesky.service.ts @@ -10,6 +10,7 @@ import { BlueskyAccountData, BlueskyFileOptions, BlueskyNotificationOptions, + SubmissionRating, } from 'postybirb-commons'; import UserAccountEntity from 'src/server//account/models/user-account.entity'; import ImageManipulator from 'src/server/file-manipulation/manipulators/image.manipulator'; @@ -25,9 +26,19 @@ import WebsiteValidator from 'src/server/utils/website-validator.util'; import { LoginResponse } from '../interfaces/login-response.interface'; import { ScalingOptions } from '../interfaces/scaling-options.interface'; import { Website } from '../website.base'; -import { BskyAgent, stringifyLex, jsonToLex, AppBskyEmbedImages, ComAtprotoLabelDefs, BlobRef, RichText, AppBskyFeedThreadgate, AtUri } from '@atproto/api'; +import { + BskyAgent, + stringifyLex, + jsonToLex, + AppBskyEmbedImages, + ComAtprotoLabelDefs, + BlobRef, + RichText, + AppBskyFeedThreadgate, + AtUri, +} from '@atproto/api'; import { PlaintextParser } from 'src/server/description-parsing/plaintext/plaintext.parser'; -import fetch from "node-fetch"; +import fetch from 'node-fetch'; import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; import FormContent from 'src/server/utils/form-content.util'; @@ -43,10 +54,10 @@ interface FetchHandlerResponse { } type ThreadgateSetting = - | {type: 'nobody'} - | {type: 'mention'} - | {type: 'following'} - | {type: 'list'; list: string}; + | { type: 'nobody' } + | { type: 'mention' } + | { type: 'following' } + | { type: 'list'; list: string }; async function fetchHandler( reqUri: string, @@ -119,7 +130,7 @@ async function fetchHandler( // End of Polyfill function getRichTextLength(text: string): number { - return new RichText({text}).graphemeLength; + return new RichText({ text }).graphemeLength; } @Injectable() @@ -208,7 +219,7 @@ export class Bluesky extends Website { password: accountData.password, }); - let profile = await agent.getProfile({actor: agent.session.did }); + let profile = await agent.getProfile({ actor: agent.session.did }); const reply = await this.getReplyRef(agent, data.options.replyToUrl); @@ -257,7 +268,7 @@ export class Bluesky extends Website { if (postResult && postResult.uri) { // Generate a friendly URL const handle = profile.data.handle; - const server = "bsky.app"; // Can't use the agent sadly, but this might change later: agent.service.hostname; + const server = 'bsky.app'; // Can't use the agent sadly, but this might change later: agent.service.hostname; const postId = postResult.uri.slice(postResult.uri.lastIndexOf('/') + 1); let friendlyUrl = `https://${server}/profile/${handle}/post/${postId}`; @@ -275,39 +286,37 @@ export class Bluesky extends Website { } } - createThreadgate( - agent: BskyAgent, - postUri: string, - fromPostThreadGate: string, - ) { + createThreadgate(agent: BskyAgent, postUri: string, fromPostThreadGate: string) { let allow: ( | AppBskyFeedThreadgate.MentionRule | AppBskyFeedThreadgate.FollowingRule | AppBskyFeedThreadgate.ListRule - )[] = [] + )[] = []; switch (fromPostThreadGate) { - case "mention": - allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}); + case 'mention': + allow.push({ $type: 'app.bsky.feed.threadgate#mentionRule' }); break; - case "following": - allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}); + case 'following': + allow.push({ $type: 'app.bsky.feed.threadgate#followingRule' }); + break; + case 'mention,following': + allow.push({ $type: 'app.bsky.feed.threadgate#followingRule' }); + allow.push({ $type: 'app.bsky.feed.threadgate#mentionRule' }); break; - case "mention,following": - allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}); - allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}); - break; default: // Leave the array empty and this sets no one - nobody mode break; - } - - const postUrip = new AtUri(postUri) - agent.api.app.bsky.feed.threadgate.create( - {repo: agent.session!.did, rkey: postUrip.rkey}, - {post: postUri, createdAt: new Date().toISOString(), allow}, - ).finally(() => { - return; - }) + } + + const postUrip = new AtUri(postUri); + agent.api.app.bsky.feed.threadgate + .create( + { repo: agent.session!.did, rkey: postUrip.rkey }, + { post: postUri, createdAt: new Date().toISOString(), allow }, + ) + .finally(() => { + return; + }); } async postNotificationSubmission( @@ -324,7 +333,7 @@ export class Bluesky extends Website { password: accountData.password, }); - let profile = await agent.getProfile({actor: agent.session.did }); + let profile = await agent.getProfile({ actor: agent.session.did }); const reply = await this.getReplyRef(agent, data.options.replyToUrl); @@ -338,22 +347,22 @@ export class Bluesky extends Website { const rt = new RichText({ text: data.description }); await rt.detectFacets(agent); - - let postResult = await agent.post({ - text: rt.text, - facets: rt.facets, - labels: labelsRecord, - ...(reply ? { reply } : {}), - }).catch(err => { - return Promise.reject( - this.createPostResponse({ message: err }), - ); - }); - + + let postResult = await agent + .post({ + text: rt.text, + facets: rt.facets, + labels: labelsRecord, + ...(reply ? { reply } : {}), + }) + .catch(err => { + return Promise.reject(this.createPostResponse({ message: err })); + }); + if (postResult && postResult.uri) { // Generate a friendly URL const handle = profile.data.handle; - const server = "bsky.app"; // Can't use the agent sadly, but this might change later: agent.service.hostname; + const server = 'bsky.app'; // Can't use the agent sadly, but this might change later: agent.service.hostname; const postId = postResult.uri.slice(postResult.uri.lastIndexOf('/') + 1); let friendlyUrl = `https://${server}/profile/${handle}/post/${postId}`; @@ -393,6 +402,8 @@ export class Bluesky extends Website { ); } + this.validateRating(submissionPart, defaultPart, warnings); + this.validateDescription(problems, warnings, submissionPart, defaultPart); files.forEach(file => { @@ -445,10 +456,32 @@ export class Bluesky extends Website { this.validateDescription(problems, warnings, submissionPart, defaultPart); this.validateReplyToUrl(problems, submissionPart.data.replyToUrl); + this.validateRating(submissionPart, defaultPart, warnings); return { problems, warnings }; } + private validateRating( + submissionPart: SubmissionPart, + defaultPart: SubmissionPart, + warnings: string[], + ) { + // Since bluesky rating is not mapped as other sited do + // we should add warning, so users will not post unlabeled images + const rating = submissionPart.data.rating || defaultPart.data.rating; + if (rating) { + // Dont really want to make warning for undefined rating + // This is handled by default part validator + if (!submissionPart.data.label_rating && rating !== SubmissionRating.GENERAL) { + warnings.push( + `Make sure that the Default rating '${ + rating ?? SubmissionRating.GENERAL + }' matches Bluesky Label Rating.`, + ); + } + } + } + private validateDescription( problems: string[], warnings: string[], @@ -460,13 +493,11 @@ export class Bluesky extends Website { ); const rt = new RichText({ text: description }); - const agent = new BskyAgent({ service: 'https://bsky.social' }) + const agent = new BskyAgent({ service: 'https://bsky.social' }); rt.detectFacets(agent); if (rt.graphemeLength > this.MAX_CHARS) { - problems.push( - `Max description length allowed is ${this.MAX_CHARS} characters.`, - ); + problems.push(`Max description length allowed is ${this.MAX_CHARS} characters.`); } else { if (description.toLowerCase().indexOf('{tags}') > -1) { this.validateInsertTags( @@ -478,14 +509,14 @@ export class Bluesky extends Website { ); } else { warnings.push(`You have not inserted the {tags} shortcut in your description; - tags will not be inserted in your post`) + tags will not be inserted in your post`); } } } private validateReplyToUrl(problems: string[], url?: string): void { - if(url?.trim() && !this.getPostIdFromUrl(url)) { - problems.push("Invalid post URL to reply to."); + if (url?.trim() && !this.getPostIdFromUrl(url)) { + problems.push('Invalid post URL to reply to.'); } } diff --git a/electron-app/src/server/websites/twitter/enums/twitter-sensitive-media-warnings.enum.ts b/electron-app/src/server/websites/twitter/enums/twitter-sensitive-media-warnings.enum.ts new file mode 100644 index 00000000..8d0d1178 --- /dev/null +++ b/electron-app/src/server/websites/twitter/enums/twitter-sensitive-media-warnings.enum.ts @@ -0,0 +1,49 @@ +// Copied from PB+ src (commons/src/enums/submission-rating.enum.ts) +export enum ESubmissionRating { + GENERAL = 'general', + MATURE = 'mature', + ADULT = 'adult', + EXTREME = 'extreme', +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace ESubmissionRating_Utils { + /** + * Get matching ESubmissionRating of given string (PB rating keys) + * @param srVal String value to convert + * @returns Matching ESubmissionRating + * @throws RangeError when srVal is an invalid rating key + */ + export function fromStringValue(srVal: string): ESubmissionRating | never { + const srIdx = Object.values(ESubmissionRating).indexOf(srVal as ESubmissionRating); + + if (srIdx == -1) throw new RangeError(`Unknown submission rating: ${srVal}`); + + return ESubmissionRating[Object.keys(ESubmissionRating)[srIdx]]; + } +} + +// Twitter's warning tags +export enum ESensitiveMediaWarnings { + OTHER = 'other', + GRAPHIC_VIOLENCE = 'graphic_violence', + ADULT_CONTENT = 'adult_content', +} + +export type ContentBlurType = 'other' | 'graphic_violence' | 'adult_content' | undefined; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ESensitiveMediaWarnings_Utils { + export function getSMWFromContentBlur(contentBlur?: ContentBlurType) { + switch (contentBlur) { + case 'other': + return ESensitiveMediaWarnings.OTHER; + case 'adult_content': + return ESensitiveMediaWarnings.ADULT_CONTENT; + case 'graphic_violence': + return ESensitiveMediaWarnings.GRAPHIC_VIOLENCE; + default: + return undefined; + } + } +} diff --git a/electron-app/src/server/websites/twitter/interfaces/twitter-media-metadata-body.interface.ts b/electron-app/src/server/websites/twitter/interfaces/twitter-media-metadata-body.interface.ts new file mode 100644 index 00000000..d5209aec --- /dev/null +++ b/electron-app/src/server/websites/twitter/interfaces/twitter-media-metadata-body.interface.ts @@ -0,0 +1,10 @@ +import { + ESensitiveMediaWarnings +} from '../enums/twitter-sensitive-media-warnings.enum'; + +// An undocumented part of Twitter's 1.1 API, yaaaay +export interface ImediaMetadataBody { + alt_text?: {text: string} + media_id: string + sensitive_media_warning?: [ESensitiveMediaWarnings] +} \ No newline at end of file diff --git a/electron-app/src/server/websites/twitter/models/api-response.model.ts b/electron-app/src/server/websites/twitter/models/api-response.model.ts new file mode 100644 index 00000000..e35232c0 --- /dev/null +++ b/electron-app/src/server/websites/twitter/models/api-response.model.ts @@ -0,0 +1,10 @@ +export class ApiResponse { + data: T; + success: boolean; + error: string; + + constructor(partial: Partial>) { + Object.assign(this, partial); + this.success = !partial.error; + } +} diff --git a/electron-app/src/server/websites/twitter/models/submission-post.model.ts b/electron-app/src/server/websites/twitter/models/submission-post.model.ts new file mode 100644 index 00000000..e43238bf --- /dev/null +++ b/electron-app/src/server/websites/twitter/models/submission-post.model.ts @@ -0,0 +1,52 @@ +import { IsDefined } from 'class-validator'; + +export interface FileUpload { + data: string; // base64 + filename: string; + contentType: string; //mime +} + +export interface RequestFile { + value: Buffer; + options: { + filename: string; + contentType: string; + }; +} + +export class SubmissionPost { + @IsDefined() + secret: string; + + @IsDefined() + token: string; + + @IsDefined() + readonly options: T; + + readonly tags: string[]; + readonly description: string; + readonly rating: string; + readonly files: FileUpload[]; + readonly title: string; + + constructor(partial: Partial>) { + this.files = []; + this.tags = []; + this.rating = 'general'; + this.title = ''; + this.description = ''; + + Object.assign(this, partial); + } + + getFilesforPost(): RequestFile[] { + return this.files.map(file => ({ + value: Buffer.from(file.data, 'base64'), + options: { + filename: file.filename, + contentType: file.contentType, + }, + })); + } +} diff --git a/electron-app/src/server/websites/twitter/models/twitter-authorization.model.ts b/electron-app/src/server/websites/twitter/models/twitter-authorization.model.ts new file mode 100644 index 00000000..4a2179b7 --- /dev/null +++ b/electron-app/src/server/websites/twitter/models/twitter-authorization.model.ts @@ -0,0 +1,19 @@ +import { IsString, IsDefined } from 'class-validator'; + +export class TwitterAuthorization { + @IsString() + @IsDefined() + readonly verifier: string; + + @IsString() + @IsDefined() + readonly oauth_token: string; + + @IsString() + @IsDefined() + readonly key: string; + + @IsString() + @IsDefined() + readonly secret: string; +} diff --git a/electron-app/src/server/websites/twitter/twitter-api.service.ts b/electron-app/src/server/websites/twitter/twitter-api.service.ts new file mode 100644 index 00000000..4e738c81 --- /dev/null +++ b/electron-app/src/server/websites/twitter/twitter-api.service.ts @@ -0,0 +1,340 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as _ from 'lodash'; +import * as request from 'request'; +import Twitter, { AccessTokenResponse, TokenResponse, TwitterOptions } from 'twitter-lite'; +import { + ContentBlurType, + ESensitiveMediaWarnings_Utils, +} from './enums/twitter-sensitive-media-warnings.enum'; +import { ImediaMetadataBody } from './interfaces/twitter-media-metadata-body.interface'; +import { ApiResponse } from './models/api-response.model'; +import { RequestFile, SubmissionPost } from './models/submission-post.model'; +import { TwitterAuthorization } from './models/twitter-authorization.model'; + +type ApiData = { + key: string; + secret: string; +}; + +// Typings borrowed from twitter-api-v2 +type TTweetReplySettingsV2 = 'mentionedUsers' | 'following' | 'everyone'; +interface SendTweetV2Params { + direct_message_deep_link?: string; + for_super_followers_only?: 'True' | 'False'; + geo?: { + place_id: string; + }; + media?: { + media_ids?: string[]; + tagged_user_ids?: string[]; + }; + poll?: { + duration_minutes: number; + options: string[]; + }; + quote_tweet_id?: string; + reply?: { + exclude_reply_user_ids?: string[]; + in_reply_to_tweet_id: string; + }; + reply_settings?: TTweetReplySettingsV2 | string; + text?: string; +} + +@Injectable() +export class TwitterAPIService { + private readonly logger = new Logger(TwitterAPIService.name); + + private readonly MAX_FILE_CHUNK: number = 5 * 1024 * 1024; + + private getClient(apiKeys: ApiData, token?: any, version?: string) { + const config: TwitterOptions = { + consumer_key: apiKeys.key, + consumer_secret: apiKeys.secret, + version: version || '1.1', + extension: version === '2' ? false : true, + }; + if (token) { + config.access_token_key = token.oauth_token; + config.access_token_secret = token.oauth_token_secret; + } + return new Twitter(config); + } + + async startAuthorization( + data: ApiData, + ): Promise> { + try { + const auth = await this.getClient(data).getRequestToken('oob'); + return new ApiResponse({ + data: { + url: `https://api.twitter.com/oauth/authenticate?oauth_token=${ + (auth as any).oauth_token + }`, + oauth_token: (auth as any).oauth_token, + }, + }); + } catch (err) { + this.logger.error(err, err.stack, 'Twitter Auth Start Failure'); + return new ApiResponse({ error: err }); + } + } + + async completeAuthorization( + data: TwitterAuthorization, + ): Promise> { + try { + const auth = await this.getClient({ key: data.key, secret: data.secret }).getAccessToken({ + oauth_token: data.oauth_token, + oauth_verifier: data.verifier, + }); + return new ApiResponse({ data: auth }); + } catch (err) { + this.logger.error(err, err.stack, 'Twitter Auth Complete Failure'); + return new ApiResponse({ error: err }); + } + } + + async post( + apiKeys: ApiData, + data: SubmissionPost<{ contentBlur: ContentBlurType }>, + ): Promise> { + const client = this.getClient(apiKeys, { + oauth_token: data.token, + oauth_token_secret: data.secret, + }); + + const tweets: SendTweetV2Params[] = []; + const tweet: SendTweetV2Params = { + text: data.description || '', + // possibly_sensitive: data.rating !== 'general', + }; + + tweet.text = (tweet?.text || '').replace(/@(\w+)/g, '@/$1'); + + let mediaIds = []; + if (data.files.length) { + // File submissions + try { + mediaIds = await Promise.all( + data.getFilesforPost().map(file => this.uploadMedia(apiKeys, client, file)), + ); + + // Get Twitter warning tag + const twitterSMW = + ESensitiveMediaWarnings_Utils.getSMWFromContentBlur(data?.options?.contentBlur) ?? + undefined; + // And apply it if any + if (twitterSMW) + await Promise.all( + mediaIds.map(mediaIdIter => { + const mediaMdBody: ImediaMetadataBody = { media_id: mediaIdIter }; + mediaMdBody.sensitive_media_warning = [twitterSMW]; + return client.post('media/metadata/create', mediaMdBody); + }), + ); + } catch (err) { + this.logger.error( + err.map(e => e.message).join('\n'), + err.stack, + 'Failed to upload files to Twitter', + ); + return new ApiResponse({ error: err.map(e => e.message).join('\n') }); + } + + const ids = _.chunk(mediaIds, 4); + ids.forEach((idGroup, i) => { + const t = { ...tweet, media: { media_ids: idGroup } }; + if (ids.length > 1) { + if (i === 0) { + const numberedStatus = `${i + 1}/${ids.length} ${t.text}`; + if (numberedStatus.length <= 280) { + t.text = numberedStatus; + } + } + if (i > 0) { + t.text = `${i + 1}/${ids.length}`; + } + } + tweets.push(t); + }); + } else { + tweets.push(tweet); + } + + try { + let url: string; + let replyId; + for (const t of tweets) { + if (replyId) { + t.reply = { + in_reply_to_tweet_id: replyId, + }; + } + const tokens = client['token']; + + const oauth = { + consumer_key: apiKeys.key, + consumer_secret: apiKeys.secret, + token: tokens.key, + token_secret: tokens.secret, + }; + const post = await this.postTweet(t, oauth); + const me = await this.getAuthenticatedUser(oauth); + if (!url) { + url = `https://twitter.com/${me.data.username}/status/${post.data.id}`; + } + replyId = post.id_str; + } + return new ApiResponse({ + data: { + url, + }, + }); + } catch (err) { + this.logger.error(err, '', 'Failed to post'); + return new ApiResponse({ + error: err.map(e => e.message).join('\n'), + }); + } + } + + private getAuthenticatedUser(oauth: any): Promise { + const url = 'https://api.twitter.com/2/users/me'; + return new Promise((resolve, reject) => { + request.get( + url, + { + json: true, + oauth, + }, + (err, res, body) => { + if (body && body.errors) { + reject(body.errors); + } else { + resolve(body); + } + }, + ); + }); + } + + private postTweet(form: any, oauth: any): Promise { + const url = 'https://api.twitter.com/2/tweets'; + return new Promise((resolve, reject) => { + request.post( + url, + { + json: true, + body: form, + oauth, + }, + (err, res, body) => { + if (body && body.errors) { + reject(body.errors); + } else { + resolve(body); + } + }, + ); + }); + } + + private async uploadMedia(apiKeys: ApiData, client: Twitter, file: RequestFile): Promise { + const init = { + command: 'INIT', + media_type: file.options.contentType, + total_bytes: file.value.length, + media_category: 'tweet_image', + }; + + if (file.options.contentType === 'image/gif') { + init.media_category = 'tweet_gif'; + } else if (!file.options.contentType.includes('image')) { + // Assume video type + init.media_category = 'tweet_video'; + } + + const url = 'https://upload.twitter.com/1.1/media/upload.json'; + const tokens = client['token']; + + const oauth = { + consumer_key: apiKeys.key, + consumer_secret: apiKeys.secret, + token: tokens.key, + token_secret: tokens.secret, + }; + + const mediaData: any = await new Promise((resolve, reject) => { + request.post( + url, + { + json: true, + form: init, + oauth, + }, + (err, res, body) => { + if (body && body.errors) { + reject(body.errors); + } else { + resolve(body); + } + }, + ); + }); + + const { media_id_string } = mediaData; + const chunks = _.chunk(file.value, this.MAX_FILE_CHUNK); + await Promise.all( + chunks.map((chunk, i) => this.uploadChunk(oauth, media_id_string, Buffer.from(chunk), i)), + ); + + await new Promise((resolve, reject) => { + request.post( + url, + { + form: { + command: 'FINALIZE', + media_id: media_id_string, + }, + json: true, + oauth, + }, + (err, res, body) => { + if (body && body.errors) { + reject(body.errors); + } else { + resolve(body); + } + }, + ); + }); + + return media_id_string; + } + + private uploadChunk(oauth: any, id: string, chunk: Buffer, index: number): Promise { + return new Promise((resolve, reject) => { + request.post( + 'https://upload.twitter.com/1.1/media/upload.json', + { + formData: { + command: 'APPEND', + media_id: id, + media_data: chunk.toString('base64'), + segment_index: index, + }, + json: true, + oauth, + }, + (err, res, body) => { + if (body && body.errors) { + reject(body.errors); + } else { + resolve(body); + } + }, + ); + }); + } +} diff --git a/electron-app/src/server/websites/twitter/twitter.controller.ts b/electron-app/src/server/websites/twitter/twitter.controller.ts new file mode 100644 index 00000000..6e0273a5 --- /dev/null +++ b/electron-app/src/server/websites/twitter/twitter.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { SubmissionPost } from './models/submission-post.model'; +import { TwitterAuthorization } from './models/twitter-authorization.model'; +import { TwitterAPIService } from './twitter-api.service'; + +@Controller('twitter') +export class TwitterController { + constructor(private readonly service: TwitterAPIService) {} + + @Get('v2/authorize') + startAuthorization(@Query() data: { key: string; secret: string }) { + return this.service.startAuthorization(data); + } + + @Post('v2/authorize') + completeAuthorization(@Body() data: TwitterAuthorization) { + return this.service.completeAuthorization(data); + } + + @Post('v2/post') + post(@Query() apiKeys: { key: string; secret: string }, @Body() data: SubmissionPost) { + return this.service.post(apiKeys, data); + } +} diff --git a/electron-app/src/server/websites/twitter/twitter.module.ts b/electron-app/src/server/websites/twitter/twitter.module.ts new file mode 100644 index 00000000..28602666 --- /dev/null +++ b/electron-app/src/server/websites/twitter/twitter.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TwitterAPIService } from './twitter-api.service'; +import { TwitterController } from './twitter.controller'; +import { Twitter } from './twitter.service'; + +@Module({ + controllers: [TwitterController], + providers: [Twitter, TwitterAPIService], + exports: [Twitter], +}) +export class TwitterModule {} diff --git a/electron-app/src/server/websites/twitter/twitter.service.ts b/electron-app/src/server/websites/twitter/twitter.service.ts new file mode 100644 index 00000000..9d10b7c5 --- /dev/null +++ b/electron-app/src/server/websites/twitter/twitter.service.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@nestjs/common'; +import { + DefaultOptions, + FileRecord, + FileSubmission, + FileSubmissionType, + PostResponse, + Submission, + SubmissionPart, + TwitterAccountData, + TwitterFileOptions, +} from 'postybirb-commons'; +import UserAccountEntity from 'src/server//account/models/user-account.entity'; +import { UsernameParser } from 'src/server/description-parsing/miscellaneous/username.parser'; +import { PlaintextParser } from 'src/server/description-parsing/plaintext/plaintext.parser'; +import ImageManipulator from 'src/server/file-manipulation/manipulators/image.manipulator'; +import { CancellationToken } from 'src/server/submission/post/cancellation/cancellation-token'; +import { FilePostData } from 'src/server/submission/post/interfaces/file-post-data.interface'; +import { PostData } from 'src/server/submission/post/interfaces/post-data.interface'; +import { ValidationParts } from 'src/server/submission/validator/interfaces/validation-parts.interface'; +import FileSize from 'src/server/utils/filesize.util'; +import FormContent from 'src/server/utils/form-content.util'; +import WebsiteValidator from 'src/server/utils/website-validator.util'; +import { LoginResponse } from '../interfaces/login-response.interface'; +import { ScalingOptions } from '../interfaces/scaling-options.interface'; +import { Website } from '../website.base'; +import { ContentBlurType } from './enums/twitter-sensitive-media-warnings.enum'; +import { SubmissionPost } from './models/submission-post.model'; +import { TwitterAPIService } from './twitter-api.service'; + +@Injectable() +export class Twitter extends Website { + MAX_CHARS: number = 280; + readonly BASE_URL = ''; + readonly acceptsAdditionalFiles = true; + readonly enableAdvertisement = false; + readonly defaultDescriptionParser = PlaintextParser.parse; + readonly acceptsFiles = ['jpeg', 'jpg', 'png', 'gif', 'webp', 'mp4', 'mov']; + readonly usernameShortcuts = [ + { + key: 'tw', + url: 'https://twitter.com/$1', + }, + ]; + + constructor(private readonly api: TwitterAPIService) { + super(); + } + + transformAccountData(data: TwitterAccountData): object { + return { key: data.key, secret: data.secret }; + } + + getScalingOptions(file: FileRecord): ScalingOptions { + return { maxSize: FileSize.MBtoBytes(5) }; + } + + preparseDescription(text: string) { + return UsernameParser.replaceText(text, 'tw', '@$1'); + } + + async checkLoginStatus(data: UserAccountEntity): Promise { + const status: LoginResponse = { loggedIn: false, username: null }; + const accountData: TwitterAccountData = data.data; + if (accountData && accountData.oauth_token) { + status.loggedIn = true; + status.username = accountData.screen_name; + } + return status; + } + + async postFileSubmission( + cancellationToken: CancellationToken, + data: FilePostData, + accountData: TwitterAccountData, + ): Promise { + let contentBlur = data?.options?.contentBlur; + + const form = new SubmissionPost<{ + contentBlur: ContentBlurType; + }>({ + token: accountData.oauth_token, + secret: accountData.oauth_token_secret, + title: '', + description: data.description, + tags: data.tags, + files: [data.primary, ...data.additional].map(f => ({ + data: f.file.value.toString('base64'), + ...f.file.options, + })), + rating: data.rating, + options: { + contentBlur, + }, + }); + + this.checkCancelled(cancellationToken); + const post = await this.api.post({ key: accountData.key, secret: accountData.secret }, form); + if (post.success) { + return this.createPostResponse({ source: post.data.url }); + } + + return Promise.reject(this.createPostResponse({ additionalInfo: post, message: post.error })); + } + + async postNotificationSubmission( + cancellationToken: CancellationToken, + data: PostData, + accountData: TwitterAccountData, + ): Promise { + const form = new SubmissionPost<{ + contentBlur: ContentBlurType; + }>({ + token: accountData.oauth_token, + secret: accountData.oauth_token_secret, + title: '', + description: data.description, + tags: data.tags, + rating: data.rating, + options: { + contentBlur: undefined, + }, + }); + + this.checkCancelled(cancellationToken); + const post = await this.api.post({ key: accountData.key, secret: accountData.secret }, form); + if (post.success) { + return this.createPostResponse({ source: post.data.url }); + } + + return Promise.reject(this.createPostResponse({ additionalInfo: post, message: post.error })); + } + + validateFileSubmission( + submission: FileSubmission, + submissionPart: SubmissionPart, + defaultPart: SubmissionPart, + ): ValidationParts { + const problems: string[] = []; + const warnings: string[] = []; + const isAutoscaling: boolean = submissionPart.data.autoScale; + + const description = PlaintextParser.parse( + FormContent.getDescription(defaultPart.data.description, submissionPart.data.description), + 23, + ); + + if (description.length > 280) { + warnings.push( + `Approximated description may surpass 280 character limit (${description.length})`, + ); + } + + const files = [ + submission.primary, + ...(submission.additional || []).filter( + f => !f.ignoredAccounts!.includes(submissionPart.accountId), + ), + ]; + + files.forEach(file => { + const { type, size, name, mimetype } = file; + if (!WebsiteValidator.supportsFileType(file, this.acceptsFiles)) { + problems.push(`Does not support file format: (${name}) ${mimetype}.`); + } + + let maxMB: number = mimetype === 'image/gif' ? 15 : 5; + if (type === FileSubmissionType.VIDEO) { + maxMB = 15; + } + if (FileSize.MBtoBytes(maxMB) < size) { + if ( + isAutoscaling && + type === FileSubmissionType.IMAGE && + ImageManipulator.isMimeType(mimetype) + ) { + warnings.push(`${name} will be scaled down to ${maxMB}MB`); + } else { + problems.push(`Twitter limits ${mimetype} to ${maxMB}MB`); + } + } + }); + + return { problems, warnings }; + } + + validateNotificationSubmission( + submission: Submission, + submissionPart: SubmissionPart, + defaultPart: SubmissionPart, + ): ValidationParts { + const warnings = []; + + const description = PlaintextParser.parse( + FormContent.getDescription(defaultPart.data.description, submissionPart.data.description), + 23, + ); + + if (description.length > 280) { + warnings.push( + `Approximated description may surpass 280 character limit (${description.length})`, + ); + } + + return { problems: [], warnings }; + } +} diff --git a/electron-app/src/server/websites/website-provider.service.ts b/electron-app/src/server/websites/website-provider.service.ts index aa05b1f4..4f839b84 100644 --- a/electron-app/src/server/websites/website-provider.service.ts +++ b/electron-app/src/server/websites/website-provider.service.ts @@ -33,6 +33,7 @@ import { Pixelfed } from './pixelfed/pixelfed.service'; import { MissKey } from './misskey/misskey.service'; import { Bluesky } from './bluesky/bluesky.service'; import { Pleroma } from './pleroma/pleroma.service'; +import { Twitter } from './twitter/twitter.service'; @Injectable() export class WebsiteProvider { @@ -73,6 +74,7 @@ export class WebsiteProvider { readonly picarto: Picarto, readonly pixelfed: Pixelfed, readonly pleroma: Pleroma, + readonly twitter: Twitter, ) { // eslint-disable-next-line this.websiteModules = [...arguments].filter(arg => arg instanceof Website); diff --git a/electron-app/src/server/websites/websites.module.ts b/electron-app/src/server/websites/websites.module.ts index 1b61c581..3d449081 100644 --- a/electron-app/src/server/websites/websites.module.ts +++ b/electron-app/src/server/websites/websites.module.ts @@ -35,6 +35,7 @@ import { PicartoModule } from './picarto/picarto.module'; import { PixelfedModule } from './pixelfed/pixelfed.module'; import { SubscribeStarAdultModule } from './subscribe-star-adult/subscribe-star-adult.module'; import { BlueskyModule } from './bluesky/bluesky.module'; +import { TwitterModule } from './twitter/twitter.module'; @Module({ controllers: [WebsitesController], @@ -74,6 +75,7 @@ import { BlueskyModule } from './bluesky/bluesky.module'; ItakuModule, PicartoModule, PixelfedModule, + TwitterModule, ], }) export class WebsitesModule {} diff --git a/package-lock.json b/package-lock.json index 6411a7d8..46c7089a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "postybirb-plus", - "version": "3.1.35", + "version": "3.1.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postybirb-plus", - "version": "3.1.35", + "version": "3.1.41", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 209f4c16..ca0eac51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus", - "version": "3.1.40", + "version": "3.1.41", "description": "PostyBirb is an application that helps artists post art and other multimedia to multiple websites more quickly..", "main": "index.js", "scripts": { diff --git a/ui/package.json b/ui/package.json index a0a54753..74ba3c87 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "postybirb-plus-ui", - "version": "3.1.40", + "version": "3.1.41", "license": "BSD-3-Clause", "private": true, "Author": "Michael DiCarlo", diff --git a/ui/src/websites/telegram/TelegramLogin.tsx b/ui/src/websites/telegram/TelegramLogin.tsx index 8df0cadd..7eff5a05 100644 --- a/ui/src/websites/telegram/TelegramLogin.tsx +++ b/ui/src/websites/telegram/TelegramLogin.tsx @@ -85,7 +85,7 @@ export default class TelegramLogin extends React.Component - You must create you own app configuration + You must create your own app configuration } diff --git a/ui/src/websites/twitter/Twitter.tsx b/ui/src/websites/twitter/Twitter.tsx new file mode 100644 index 00000000..a546ee66 --- /dev/null +++ b/ui/src/websites/twitter/Twitter.tsx @@ -0,0 +1,87 @@ +import { Form, Radio } from 'antd'; +import { + DefaultFileOptions, + DefaultOptions, + FileSubmission, + Submission, + SubmissionRating, + TwitterFileOptions, +} from 'postybirb-commons'; +import React from 'react'; +import { WebsiteSectionProps } from '../form-sections/website-form-section.interface'; +import GenericFileSubmissionSection from '../generic/GenericFileSubmissionSection'; +import GenericSubmissionSection from '../generic/GenericSubmissionSection'; +import { LoginDialogProps } from '../interfaces/website.interface'; +import { WebsiteImpl } from '../website.base'; +import { TwitterLogin } from './TwitterLogin'; + +export class Twitter extends WebsiteImpl { + internalName: string = 'Twitter'; + name: string = 'Twitter'; + supportsAdditionalFiles: boolean = true; + supportsTags = false; + loginUrl: string = ''; + + LoginDialog = (props: LoginDialogProps) => ; + + FileSubmissionForm = (props: WebsiteSectionProps) => ( + + ); + + NotificationSubmissionForm = (props: WebsiteSectionProps) => ( + + ); +} + +export class TwitterFileSubmissionForm extends GenericFileSubmissionSection { + renderLeftForm(data: TwitterFileOptions) { + const elements = super.renderLeftForm(data); + elements.push( + + + None + Other + Adult Content + Graphic Violence + + , + ); + return elements; + } + + renderRightForm(data: TwitterFileOptions) { + const elements = super.renderRightForm(data); + return elements; + } +} diff --git a/ui/src/websites/twitter/TwitterLogin.tsx b/ui/src/websites/twitter/TwitterLogin.tsx new file mode 100644 index 00000000..8cec62dc --- /dev/null +++ b/ui/src/websites/twitter/TwitterLogin.tsx @@ -0,0 +1,206 @@ +import { Button, Form, Icon, Input, message, Spin } from 'antd'; +import { TwitterAccountData } from 'postybirb-commons'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import BrowserLink from '../../components/BrowserLink'; +import LoginService from '../../services/login.service'; +import axios from '../../utils/http'; +import { LoginDialogProps } from '../interfaces/website.interface'; + +interface State { + loading: boolean; + pin: string; + secret: string; + key: string; +} + +export class TwitterLogin extends React.Component { + state: State = { + secret: '', + key: '', + loading: true, + pin: '', + }; + + private oauth_token: string = ''; + + constructor(props: LoginDialogProps) { + super(props); + this.state = { + ...this.state, + ...(props.data as State), + }; + } + + componentDidMount() { + const node = ReactDOM.findDOMNode(this); + if (node instanceof HTMLElement) { + const view: any = node.querySelector('.webview'); + if (!view) return; // Webview not found + view.addEventListener('did-stop-loading', () => { + if (this.state.loading) this.setState({ loading: false }); + }); + view.allowpopups = true; + view.partition = `persist:${this.props.account._id}`; + axios + .get<{ data: { url: string; oauth_token: string } }>(`/twitter/v2/authorize`, { + params: { + key: this.state.key, + secret: this.state.secret, + }, + }) + .then(({ data }) => { + view.src = data.data.url; + this.oauth_token = data.data.oauth_token; + this.setState({ loading: false }); + }) + .catch(() => { + message.error('A problem occurred when attempting to contact authentication server.'); + }); + } + } + + updateAuthData(data: TwitterAccountData) { + LoginService.setAccountData(this.props.account._id, data).then(() => { + message.success('Twitter Authenticated.'); + }); + } + + isValid(): boolean { + return !!this.state.pin; + } + + isAPIValid(): boolean { + return !!this.state.key && !!this.state.secret; + } + + submitAPI() { + LoginService.setAccountData(this.props.account._id, { + key: this.state.key, + secret: this.state.secret, + }) + .then(() => { + message.success('Twitter API Key and Secret saved.'); + }) + .finally(() => { + this.componentDidMount(); + }); + } + + submit() { + axios + .post<{ success: boolean; error: string; data: any }>( + `/twitter/v2/authorize/`, + { + verifier: this.state.pin.trim(), + oauth_token: this.oauth_token, + key: this.state.key, + secret: this.state.secret, + }, + { responseType: 'json' }, + ) + .then(({ data }) => { + if (data.success) { + LoginService.setAccountData(this.props.account._id, { + secret: this.state.secret, + key: this.state.key, + ...data.data, + }).then(() => { + message.success('Twitter authenticated.'); + }); + } else { + message.error(data.error); + } + }) + .catch(() => { + message.error('Failed to authenticate Twitter.'); + }); + } + + render() { + if (!this.isAPIValid()) { + return ( +
+
+
+
+

Twitter API Key and Secret

+

+ + How to get your API Key and Secret + +

+
+ + this.setState({ key: target.value })} + /> + + + this.setState({ secret: target.value })} + /> + + +
+
+
+ ); + } + + return ( +
+
+
+
+

Twitter API Key and Secret

+

+ + How to get your API Key and Secret + +

+
+ + this.setState({ key: target.value })} + /> + + + this.setState({ secret: target.value })} + /> + + + + this.setState({ pin: target.value })} + addonAfter={ + + } + /> + +
+
+ + + +
+ ); + } +} diff --git a/ui/src/websites/website-registry.ts b/ui/src/websites/website-registry.ts index 22c37b42..7491081c 100644 --- a/ui/src/websites/website-registry.ts +++ b/ui/src/websites/website-registry.ts @@ -32,6 +32,7 @@ import { Picarto } from './picarto/Picarto'; import { SubscribeStarAdult } from './subscribe-star/SubscribeStarAdult'; import { Pixelfed } from './pixelfed/Pixelfed'; import { Bluesky } from './bluesky/Bluesky'; +import { Twitter } from './twitter/Twitter'; export class WebsiteRegistry { static readonly websites: Record = { @@ -67,7 +68,8 @@ export class WebsiteRegistry { Telegram: new Telegram(), Tumblr: new Tumblr(), Weasyl: new Weasyl(), - e621: new e621() + e621: new e621(), + Twitter: new Twitter(), }; static getAllAsArray() {