diff --git a/.env.dist b/.env.dist index 14bc172..6a5e075 100644 --- a/.env.dist +++ b/.env.dist @@ -1,5 +1,106 @@ -AYRSHARE_API_KEY=xx -AYRSHARE_FEEDPATH=feed -AYRSHARE_INTERVAL=6 #days -AYRSHARE_PLATFORMS=youtube,facebook,linkedin,instagram,tiktok,reddit,twitter -AYRSHARE_SUBREDDIT=generative \ No newline at end of file +# app settings +FAIRPOST_UI=cli +FAIRPOST_FILE_SYSTEM=localfs +FAIRPOST_LOGGER_CONFIG=log4js.json +FAIRPOST_LOGGER_CATEGORY=default +FAIRPOST_LOGGER_LEVEL=trace +FAIRPOST_LOGGER_CONSOLE=false +FAIRPOST_STORAGE_APP=env +FAIRPOST_STORAGE_SETTINGS=json-env +FAIRPOST_STORAGE_AUTH=json +FAIRPOST_STORAGE_CACHE=json + +# user settings +FAIRPOST_USER_HOMEDIR=users/%user% +FAIRPOST_USER_JSONPATH=storage.json +FAIRPOST_USER_FEEDPATH= # use stage folders in users homedir +FAIRPOST_USER_AUTH=fairpost # fairpost / cognito + +# cli oauth client settings +FAIRPOST_OAUTH_USERAGENT=Fairpost 1.0 +FAIRPOST_OAUTH_HOSTNAME=localhost +FAIRPOST_OAUTH_PORT=8000 + +# rest api server settings +FAIRPOST_SERVER_BIND=localhost +FAIRPOST_SERVER_PORT=8000 +FAIRPOST_SERVER_CORS=* +FAIRPOST_SESSION_SECURE=true +FAIRPOST_SESSION_SAMESITE=strict # strict|lax|none +FAIRPOST_SESSION_TIMEOUT=3600 + +# user feed settings (defaults) +FAIRPOST_FEED_INTERVAL=6 #days +FAIRPOST_FEED_PLATFORMS= + +# source folder names +FAIRPOST_SOURCES_INCOMING=incoming +FAIRPOST_SOURCES_PENDING=pending +FAIRPOST_SOURCES_ACTIVE=active +FAIRPOST_SOURCES_FINISHED=finished +FAIRPOST_SOURCES_ARCHIVED=archived + + +# --------------- +# facebook +# --------------- +# FAIRPOST_FACEBOOK_APP_ID=xxx +# FAIRPOST_FACEBOOK_APP_SECRET=xxx +FAIRPOST_FACEBOOK_PLUGINS=LimitFiles,ImageSize + +# --------------- +# instagram +# --------------- +# FAIRPOST_INSTAGRAM_APP_ID=xxx +# FAIRPOST_INSTAGRAM_APP_SECRET=xxx +FAIRPOST_INSTAGRAM_PLUGINS=LimitFiles,ImageSize + +# --------------- +# linkedin +# --------------- +# FAIRPOST_LINKEDIN_CLIENT_ID=xxx +# FAIRPOST_LINKEDIN_CLIENT_SECRET=xxx +FAIRPOST_LINKEDIN_PLUGINS=LimitFiles,ImageSize + + +# --------------- +# twitter +# --------------- +# FAIRPOST_TWITTER_CLIENT_ID=xxx +# FAIRPOST_TWITTER_CLIENT_SECRET=xxx +# FAIRPOST_TWITTER_OA1_API_KEY=xxx +# FAIRPOST_TWITTER_OA1_API_KEY_SECRET=xxx +# FAIRPOST_TWITTER_OA1_ACCESS_TOKEN=xxx +# FAIRPOST_TWITTER_OA1_ACCESS_SECRET=xxx +FAIRPOST_TWITTER_PLUGINS=LimitFiles,ImageSize + + +# --------------- +# reddit +# --------------- +# FAIRPOST_REDDIT_CLIENT_ID=xxx +# FAIRPOST_REDDIT_CLIENT_SECRET=xxx +FAIRPOST_REDDIT_PLUGINS=LimitFiles,ImageSize + + +# --------------- +# youtube +# --------------- +# FAIRPOST_YOUTUBE_CLIENT_ID=xxx +# FAIRPOST_YOUTUBE_CLIENT_SECRET=xxx +FAIRPOST_YOUTUBE_PLUGINS=LimitFiles,ImageSize + +# --------------- +# bluesky +# --------------- +# FAIRPOST_BLUESKY_CRYPT_SECRET=xxxx +FAIRPOST_BLUESKY_PLUGINS=LimitFiles,ImageSize + +# --------------- +# tiktok - unsupported +# --------------- +# FAIRPOST_TIKTOK_APP_ID=xxx +# FAIRPOST_TIKTOK_CLIENT_KEY=xxx +# FAIRPOST_TIKTOK_CLIENT_SECRET=xxx +FAIRPOST_TIKTOK_PLUGINS=LimitFiles,ImageSize + diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..047eb5b --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,29 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: QA + +on: + pull_request: + branches: [ "develop", "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + - run: npm test diff --git a/.gitignore b/.gitignore index 439ac59..bb85f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ -# Feed +# Fairpost feed +users +var .DS_Store -package-lock.json +build +*.log # Logs logs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544b7b4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 960a1c5..e531980 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,229 @@ -# Fayrshare + + +# Fairpost + +Fairpost helps you manage users social media feeds from a single +entry point, using Node. It supports Facebook, Instagram, +Reddit, Twitter, YouTube and LinkedIn. + +A Feed is just a folder on disk, where all subfolders contain +Source Posts, each containing at least one text file (the post body) and +optionally images or video. The Source Post will be transformed +into real posts for each connected platform, then published on +that platform, then archived. During the process, the source +folder moves through the stages in the folder. + +Fairpost is *opinionated*, meaning, it will decide +how a Source Post with contents can best be presented +as a Post on each platform. + +For each platform, you'll have to register the app on the +platform. This usually results in an AppId and AppSecret or +something similar, which should be stored in global config. + +Then for each user, you'll have to allow the app to +post on their behalf. This is usually done via an +online (oauth) consent page in a webbrowser. + +Commonly, you would call this script every day or week +for every user. Fairpost can then automatically **prepare** the folders, +**schedule** the next post using a certain interval and +**publish** any post when it is due. All the user has to do is +add folders with content in the feeds 'incoming' folder. + +Or, if you prefer, you can manually publish one +specific source as posts on all supported and enabled +platforms at once, or just one source on one platform, +etcetera. + + +## Setting up ``` -tsc --config .env-fayrshare && build/fayrshare.js +# install +npm install + +# compile typescript code +npm run build + +# copy and edit fairpost config file +cp .env.dist .env && nano .env + +# run +./fairpost.js help +``` + +### Set up platforms + +Read how to set up various social media platforms in the [docs](docs). + +### Create a user and connect a platform + +Read how to connect various social media platforms in the [docs](docs); +but in general, the steps are + +``` +# create a user foobar +./fairpost.js @foobar create-user + +# edit the users storage.json, finetune settings +# and enable platform `bla` +nano users/foobar/var/lib/storage.json + +# connect platform `bla` to user `foobar` +./fairpost.js @foobar connect-platform --platform=bla + +``` + +## Feed planning +### Prepare +``` +fairpost.js prepare-posts +``` +Sources need to be `prepared` (iow turned into posts) +before they can be published to a platform. +Each platform, as defined in src/platforms, will +handle the folder contents by itself. It may +decide to modify the media (eg, scale images) +before posting, or not to post the folder (eg, +when it only contains images and the platform +is youtube). Finally, it will add a json file +describing the post for that platform in the +folder. + +As soon as at least one post is prepared, the +source moves from the `incoming` to the `pending` +stage. + +### Schedule ``` +fairpost.js schedule-next-post +``` +The next post can then be `scheduled`. For each platform, +if there is not already a scheduled post, this will update +the json file in one post to set the status to scheduled, +and set the schedule date. +By default the date will be `FAIRPOST_FEED_INTERVAL` days +after the last post for that platform, or `now`, whichever +is latest. + +As soon as at least one post is scheduled, the source +is moved from the `pending` to the `active` stage. + +`schedule-next-post` only looks in `pending` and `active` sources +for unscheduled posts and only in `active` and `finished` sources +for the last published post. + + +### Publish +``` +fairpost.js publish-due-posts +``` +This will publish any scheduled posts that are past their due date. + +Once all posts are either published or canceled, +the source is moved from the `active` to the `finished` +stage. + +## Other commands + +Other commands and `--arguments` +may help you to, for example, immediately publish +a certain post to a certain platform if you like. + +### Refresh tokens + +Access and refresh tokens for various platforms may +expire sooner or later. Before you do anything, try +`fairpost.js @userid refresh-platforms`. Eventually, even +refresh tokens may expire, and you will have to run +`fairpost.js @userid connect-platform --platform=bla` again +to get a new pair of tokens. + + +### Interface + +``` +# basic commands: +fairpost: help +fairpost: @userid get-user +fairpost: @userid put-user << payload +fairpost: @userid edit-user (cli only) +fairpost: @userid get-feed +fairpost: @userid put-feed << payload +fairpost: @userid edit-feed (cli only) +fairpost: @userid get-platform --platform=xxx +fairpost: @userid put-platform --platform=xxx << payload +fairpost: @userid edit-platform --platform=xxx (cli only) +fairpost: @userid get-platforms [--platforms=xxx,xxx] +fairpost: @userid connect-platform --platform=xxx +fairpost: @userid test-platform --platform=xxx +fairpost: @userid test-platforms [--platforms=xxx,xxx] +fairpost: @userid refresh-platform --platform=xxx +fairpost: @userid refresh-platforms [--platforms=xxx,xxx] +fairpost: @userid get-source --source=xxx [--stage=xxx] +fairpost: @userid put-source << payload +fairpost: @userid edit-source (cli only) +fairpost: @userid get-sources [--sources=xxx,xxx|--stage=xxx] +fairpost: @userid get-post --post=xxx:xxx +fairpost: @userid put-post << payload +fairpost: @userid edit-post (cli only) +fairpost: @userid get-posts [--status=xxx] [--sources=xxx,xxx|--stage=xxx] [--platforms=xxx,xxx] +fairpost: @userid prepare-post --post=xxx:xxx +fairpost: @userid schedule-post --post=xxx:xxx --date=xxxx-xx-xx +fairpost: @userid schedule-posts [--source=xxx] [--platforms=xxx,xxx|--platform=xxx] --date=xxxx-xx-xx +fairpost: @userid schedule-next-post --platform=xxx [--date=xxxx-xx-xx] [--sources=xxx,xxx|--stage=xxx] +fairpost: @userid publish-post --post=xxx:xxx [--dry-run] +fairpost: @userid publish-posts [--source=xxx] [--platforms=xxx,xxx|--platform=xxx] +fairpost: @userid set-status [--post=xxx:xxx|--posts=xxx:xxx,xxx:xxx] +fairpost: @userid get-fields --model=user|feed|platform|source|post [--platform=xxx] + +# feed planning: +fairpost: @userid prepare-posts [--sources=xxx,xxx|--source=xxx|--stage=xxx] [--platforms=xxx,xxx|--platform=xxx] +fairpost: @userid schedule-next-posts [--date=xxxx-xx-xx] [--sources=xxx,xxx|--stage] [--platforms=xxx,xxx] +fairpost: @userid publish-due-posts [--sources=xxx,xxx|--stage=xxx] [--platforms=xxx,xxx] [--dry-run] + +# account mgmt: +fairpost: @userid login --password=xxx +fairpost: @userid logout +fairpost: @userid set-password --password=xxx +fairpost: @userid refresh-token + +# admin only: +fairpost: @userid create-user +fairpost: serve +``` + +### Common arguments + +``` +# Set the cli output format to pure json +fairpost.js [command] [arguments] --output=json + +# Enable trace logging output to the console (overriding .env) +fairpost.js [command] [arguments] --verbose + +``` + + +## Add a new platform -Ayrshare feed helps you manage your ayrshare -feed from the command line. +To add support for a new platform, add a class to `src/platforms` +extending `src/classes/Platform`. You want to override at least the +method `preparePost(source)` and `publishPost(post,dryrun)`. -Each post is a folder, containing -- body.txt (txt, required) -- images (png,jpg,optional) -- one video (mp4, optional) -after posting, a postfile is added (post.json) +Then import your class and add a `platformId` for your platform +in `src/platforms/index.ts` and enable your platformId in your `.env`. -The script typically determines the post type -by looking at the folders contents, -and decides which platforms that post type -is suitable for. +Similarly, you can copy one platform, rename it and edit it to your +likings, give it a different `platformId` and enable that. -Posts typically sit in a `feed` folder; -this script typically checks which posts -have not been processed yet and processes -these, unless you specify posts on the -command line. +For more detailed instructions look at [How to add a new platform](./docs/NewPlatform.md) -The first post is scheduled at an interval -after the last successful post, or today, -unless a date is given on the command line. -Subsequent posts are scheduled at an interval -after each previous successful post. +Oh, and send me a PR if you create anything useful :-) -TODO -The script also processes the media, resizing -images and cropping video where needed. Specific -limits for each platform are applied automatically. -## cli -node index.js - --dry-run - --debug - --platforms=x[,y,..]|all - --date=yyyy-mm-dd|now - --posts=dir1[,dir2,..]|feed - --interval=7 - --type=text|images|video|auto diff --git a/docs/Bluesky.md b/docs/Bluesky.md new file mode 100644 index 0000000..53d7a2f --- /dev/null +++ b/docs/Bluesky.md @@ -0,0 +1,141 @@ +# Platform: Blueksy + +The `bluesky` platform manages your feed +using the ATProto protocol with service +https://bsky.social + +## Set up the platform + +There is nothing to set up. The user supplies +the app with a password, and then the app can +post on the users behalf. + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=bluesky` + +## Connect the platform to another user + +You can't. There is only one identifier/password +combination per fairpost user. + +## More user settings + +None. + +# Limitations + +From https://www.ayrshare.com/docs/media-guidelines/bluesky + +## Images + +Max image size: 1 MB. +Supported formats: JPG, Animated GIF, and PNG. +Recommended size for images: 1200 x 627 px. +Annimated GIF will be sent as a video. Only one animated GIF can be sent per post. +​ +## Video + +Max video size: 1 GB +Supported formats: MP4. +Duration max: 4 minutes. +Duration min: 1 second. +Aspect ratio must be between 1:3 and 3:1. + +# Random documentation +Bluesky does not use oauth yet, so fairposts asks +the user to create an app password, like so +""" +To let our app post on your behalf, Bluesky requires an App Password. +We will never ask for your main password. +You can create an app password here: +https://bsky.app/settings/app-passwords + +Once created, paste it below. You can revoke this at any time from your Bluesky settings. +""" + +You can save the JWT (token), but you cant refresh it +without the app password. Store this encrypted. +~~~ + +convert at:uris to urls + +https://github.com/notjuliet/pdsls/blob/74d45a3a56149d706fe950e2a7123a526d4ac5cf/src/views/record.tsx#L146-L197 + +~~~~ + +## Set up the platform + +``` +await agent.login({ identifier, password }) +agent.session = { + accessJwt: '...', + refreshJwt: '...', + did: 'did:plc:...', + handle: 'your-handle.bsky.social' +} +saveToStorage(userId, session) // You define this +... +const savedSession = loadFromStorage(userId) +const agent = new BskyAgent({ service: 'https://bsky.social' }) +agent.session = savedSession +``` + +If the JWT expires, you'll get a 401, and must call login() again. + +``` +async function isSessionValid(agent) { + try { + await agent.api.com.atproto.server.getSession() + return true + } catch (err) { + if ( + err?.response?.status === 401 || + (typeof err.message === 'string' && + (err.message.includes('Unauthorized') || + err.message.includes('expired') || + err.message.includes('invalid token'))) + ) { + return false + } + throw err // some other error + } +} +``` + + +post + +https://www.ayrshare.com/complete-guide-to-bluesky-api-integration-authorization-posting-analytics-comments/ +https://docs.bsky.app/docs/get-started +https://docs.bsky.app/docs/starter-templates/bots + + +session +``` +{ + accessJwt: 'xxx', + refreshJwt: 'xxx', + handle: 'fairpostor.bsky.social', + did: 'did:plc:ifa6qu7emplazfdpnrcmj54i', + email: 'pike+fairpostor@kw.nl', + emailConfirmed: true, + emailAuthFactor: false, + active: true, + status: undefined +} +``` + + + +post response +``` +{ + uri: 'at://did:plc:ifa6qu7emplazfdpnrcmj54i/app.bsky.feed.post/3ltc2untwpu2a', + cid: 'bafyreig46rwltcusvzk6jrpki34rhdbkvzl4ue3lobqtptyoesdqzc5ne4', + commit: { + cid: 'bafyreifgmizsdgomjpv7bwcmojtazj2jhw4lr2jec5vrdhz3b6xikuy7re', + rev: '3ltc2unu6ju2a' + }, + validationStatus: 'valid' +} +``` diff --git a/docs/Facebook.md b/docs/Facebook.md new file mode 100644 index 0000000..682919b --- /dev/null +++ b/docs/Facebook.md @@ -0,0 +1,132 @@ +# Platform: Facebook + +The `facebook` platform manages a facebook **page** (not your feed) +using the plain graph api - no extensions installed. + + +## Set up the platform + + +### Create a new App in your facebook account + - go to https://developers.facebook.com/ + - create an app that can manage pages + - under 'settings', find your app ID + - save this as `FAIRPOST_FACEBOOK_APP_ID` in your global .env + - under 'settings', find your app secret + - save this as `FAIRPOST_FACEBOOK_APP_SECRET` in your global .env + - keep the app under development, otherwise the localhost return url wont work + +## Connect the platform to a user + +### Enable the platform + - Add 'facebook' to your `FEED_PLATFORMS` in your users `storage.json` + + +### Find the page id of the page you want the app to manage + - go to https://business.facebook.com/ + - find your page (currently under 'settings > accounts > pages') + - save the page id as `FACEBOOK_PAGE_ID` in your users `storage.json` + +### Get a (long lived) Page Access Token for the page you want the app to manage + +This token should last forever. It involves getting a user access token, +exchanging it for a long-lived user token and +then requesting the 'accounts' for your 'app scoped user id'; +but this app provides a tool to help you do that. + +Requesting access tokens only works + - in dev mode and for users that can manage the app + - or in live mode if the app has advanced access permissions + +To get advanced access permissions, the app has to go +through a review. Below, I will assume you use dev +mode when requesting the tokens. Once you have the +tokens, you can turn on Live mode and start posting. + + +- set your app back in dev mode + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'dev' +- call `./fairpost.js @userid connect-platform --platform=facebook` +- follow instructions from the command line + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=facebook` + +### Set the App to Live Mode +before you use the app, set the App Mode to 'Live' + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'live' + - use https://github.com/commonpike/fairpost/blob/master/public/privacy-policy.md for the privacy policy url + + + +## Connect the platform to another user + +One fairpost user can only manage one page. If you create a second user, you can use the same app to manage a different page. The app is registered on your account, so if you can manage the other page, so can the app. + +### Add a second user +- call `./fairpost.js create-user --userid=foo` + +### Enable the app on the other page + +- Go to https://www.facebook.com/settings/?tab=business_tools +- edit the app and check the boxes of the other pages you want to manage. + +### Get a access token for the other page + +- set your app back in dev mode + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'dev' +- call `./fairpost.js @foo connect-platform --platform=facebook` +- follow instructions from the command line +- put your app back in live mode + +### Test the platform for the other page + - call `./fairpost.js @foo test-platform --platform=facebook` + +## More user settings + + - `FACEBOOK_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + - `FACEBOOK_PUBLISH_POSTS` - if false, posts will be posted but not be published + +# Limitations + +## Images + +From https://developers.facebook.com/docs/graph-api/reference/page/photos/ : + +Facebook strips all location metadata before publishing and resizes images to different dimensions to best support rendering in multiple sizes. + + +### Supported Formats +Facebook supports the following formats: + - JPEG + - BMP + - PNG + - GIF + - TIFF + +### File Size + +Files must be 4MB or smaller in size. +For PNG files, try keep the file size below 1 MB. PNG files larger than 1 MB may appear pixelated after upload. + +# Random documentation + +https://dev.to/xaypanya/how-to-connect-your-nodejs-server-to-facebook-page-api-1hol +https://developers.facebook.com/docs/pages/getting-started +https://developers.facebook.com/docs/pages-api/posts +https://developers.facebook.com/docs/graph-api/reference/page/photos/ +https://developers.facebook.com/docs/video-api/guides/publishing + +large uploads: +https://developers.facebook.com/docs/graph-api/guides/upload/ + +https://www.npmjs.com/package/formdata-node +https://medium.com/deno-the-complete-reference/sending-form-data-using-fetch-in-node-js-8cedd0b2af85 + +https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow \ No newline at end of file diff --git a/docs/Fairpost.md b/docs/Fairpost.md new file mode 100644 index 0000000..ed1c4a1 --- /dev/null +++ b/docs/Fairpost.md @@ -0,0 +1,71 @@ +# Fairpost: Basic structure + +The singleton `Fairpost.ts` is the internal API of Fairpost. +It is an executioner of commands, executed by an `Operator` +optionally on a `User`. An interface using Fairpost only has +to speak with this singleton. + + + +## Roles, permissions + +The callee of the Fairpost commands constructs the operator and the +optional user; the operator is assigned one or more 'roles' +that will later give it 'permissions' to execute the command. +Fairpost is the main place where these permissions are checked. + +## Class structure + +- User + - Feed + - feed.sources[] + - Source (data, files) + - Platforms[] + - Platform + - platform.posts[] + - Post (data, files) + +Posts are prepared from a Source for each Platform. + +The singleton dives into this class structure to execute +the command. For example, `get-post` executes something like +``` +const source = user.getFeed().getSource(sourceId); +const platform = user.getPlatform(platformId); +const post = source.getPost(platform); +``` + +## Post Status and Source Stage + +The stage of a source depends on the statuses of +all posts in the source. The first stage is +`incoming`, the final stage is `archived`. +The path to the source folder, containing all +the posts, is defined by the soure stage. + +## DTOs + +All relevant models have associated 'mappers' which +can return or receive a 'DTO' of an instance of that model. +These DTOs are returned (or received) by the API. The contents +of the DTO is also determined by the operators permissions. +To support this, all mappers have a FieldMapping that +describes each field of the DTO in detail. + +## Logging + +The Fairpost singleton has its own log, but each User +also has a log. Within the user, the log level can be +changed to better debug issues for the user without +affecting the global log. + +## Paths + +Within a User, Feed or Platform, paths are noted as relative +to the users homedir. This includes the path of a source. +This way, you can swap, copy or rename users without having +to edit any data. + +Within a Source or Post, paths are noted as relative to the +source. This way, you can swap, copy or rename sources or +posts without having to edit any data. diff --git a/docs/Instagram.md b/docs/Instagram.md new file mode 100644 index 0000000..27501bf --- /dev/null +++ b/docs/Instagram.md @@ -0,0 +1,147 @@ +# Platform: Instagram + +The `instagram` platform manage a instagram account +that is connected to a facebook **page** +using the plain facebook graph api - no extensions installed. + +It publishes **photo**, **video**, or +**carousels** posts on that instagram account. + +It uses the related facebook account to +upload temporary files, because the instagram +api requires files in posts to have an url. + + +## Set up the platform + + +### Create a new App in your facebook account + - create an Instagram business account + - connect a Facebook page to your Instagram business account + - find that pages id and + - save this as `INSTAGRAM_PAGE_ID` in your users storage.json + - go to https://developers.facebook.com/ + - create an app that can manage pages + - include the "Instagram Graph API" product as a new product + - under 'settings', find your app ID + - save this as `FAIRPOST_INSTAGRAM_APP_ID` in your global .env + - under 'settings', find your app secret + - save this as `FAIRPOST_INSTAGRAM_APP_SECRET` in your global .env + +## Connect the platform to a user + +### Find your instagram user id + - go to https://www.instagram.com/web/search/topsearch/?query={username} + - find your fbid_v2 + - save this as `INSTAGRAM_USER_ID` in your users storage.json + +### Enable the platform + - Add 'instagram' to your `FEED_PLATFORMS` in your users `storage.json` + +### Get a (long lived) Page Access Token for the page you want the app to manage + +This token should last forever. It involves getting a user access token, +exchaning it for a long-lived user token and +then requesting the 'accounts' for your 'app scoped user id'; +but this app provides a tool to help you do that. + +Requesting access tokens only works + - in dev mode and for users that can manage the app + - or in live mode if the app has advanced access permissions + +To get advanced access permissions, the app has to go +through a review. Below, I will assume you use dev +mode when requesting the tokens. Once you have the +tokens, you can turn on Live mode and start posting. + + +- set your app back in dev mode + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'dev' +- call `./fairpost.js @userid connect-platform --platform=instagram` +- follow instructions from the command line + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=instagram` + +### Set the App to Live Mode +before you use the app, set the App Mode to 'Live' + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'live' + - use https://github.com/commonpike/fairpost/blob/master/public/privacy-policy.md for the privacy policy url + +## Connect the platform to another user + +One fairpost user can only manage one page. If you create a second user, you can use the same app id to manage a different page. The app is registered on your account, so if you can manage the other page, so can the app. + +### Add a second user +- call `./fairpost.js create-user --userid=foo` + +### Find your other instagram user id + - go to https://www.instagram.com/web/search/topsearch/?query={username} + - find your fbid_v2 + - save this as `INSTAGRAM_USER_ID` in your users storage.json + +### Enable the app on the other page +- Go to https://www.facebook.com/settings/?tab=business_tools +- edit the app and check the boxes of the other pages you want to manage. + +### Get a access token for the other page + +- set your app back in dev mode + - go to https://developers.facebook.com/ + - select your app, edit it + - set App Mode to 'dev' +- call `./fairpost.js @foo connect-platform --platform=instagram` +- follow instructions from the command line +- put your app back in live mode + +### Test the platform for the other page + - call `./fairpost.js @foo test-platform --platform=instagram` + +## More user settings + + - `INSTAGRAM_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + +# Limitations + +## Images + +- Carousels are limited to 10 images, videos, or a mix of the two. +- Carousel images are all cropped based on the first image in the carousel, with the default being a 1:1 aspect ratio. + + +### Supported Formats +Instagram supports the following formats: + - JPEG + +### File Size + +xxx + +# Random documentation + +https://developers.facebook.com/docs/instagram-api/guides/content-publishing + +- only jpeg +- rate limit w endpoint +- upload media first + +POST /{ig-user-id}/media — upload media and create media containers. +POST /{ig-user-id}/media_publish — publish uploaded media using their media containers. +GET /{ig-container-id}?fields=status_code — check media container publishing eligibility and status. +GET /{ig-user-id}/content_publishing_limit — check app user's current publishing rate limit usage. + +~~~ +permalink +https://developers.facebook.com/docs/instagram-basic-display-api/reference/media/ +~~~ +GET /{ig-container-id}?fields=status_code endpoint. This endpoint will return one of the following: + +EXPIRED — The container was not published within 24 hours and has expired. +ERROR — The container failed to complete the publishing process. +FINISHED — The container and its media object are ready to be published. +IN_PROGRESS — The container is still in the publishing process. +PUBLISHED — The container's media object has been published. diff --git a/docs/LinkedIn.md b/docs/LinkedIn.md new file mode 100644 index 0000000..2a67233 --- /dev/null +++ b/docs/LinkedIn.md @@ -0,0 +1,166 @@ +# Platform: LinkedIn + +The LinkedIn platform posts to your companies feed. + +## Set up the platform + +### Create a new App in your linkedin account +- create an company your account can manage +- find your company id (in the url, like , 93841222) + - save this as `LINKEDIN_COMPANY_ID` in your users storage.json +- create an app to manage the company page \ +https://www.linkedin.com/developers/apps/new +- add 'share on linkedin' product on your app +- add 'advertising api' product to the app + - this requires a lengthy application form - wait for approval +- on the 'settings' tab of your app + - click the 'verify' button next to the page you want the app to manage \ + and follow instructions there +- on the 'auth' tab of your app + - copy ClientID and ClientSecret \ + and save those as `FAIRPOST_LINKEDIN_CLIENT_ID` and `FAIRPOST_LINKEDIN_CLIENT_SECRET` \ + in your global `.env` + - add redirect url for your app as set in your .env (http://localhost:8000/callback) + +## Connect the platform to a user + +### Enable the platform + - Add 'linkedin' to your `FEED_PLATFORMS` in your users `storage.json` + +### Get an OAuth2 Access Token for your platform + +This token last for 60 days and should be refreshed. +The refresh token (if given) lasts for 1 year. + + - call `./fairpost.js @userid connect-platform --platform=linkedin` + - follow instructions from the command line + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=linkedin` + +## Connect the platform to another user + +One fairpost user can only manage one page. If you create a second user, you can use the same app id to manage a different page. The app is registered on your account, so if you can manage the other page, so can the app. + +### Create a new user +- call `./fairpost.js create-user --userid=foo` +- add linkedin to its FAIRPOST_PLATFORMS +- find your company id (in the url, like , 93841222) + - save this as `LINKEDIN_COMPANY_ID` in your users storage.json + +### Get an OAuth2 Access Token for your other page + + - call `./fairpost.js @foo connect-platform --platform=linkedin` + - follow instructions from the command line + +### Test the other installation + - call `./fairpost.js @foo test-platform --platform=linkedin` + +## More user settings + + - `LINKEDIN_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + +# Limitations + +## Images + +... + +### Supported Formats + +... + +### File Size + +xxx + +## video + +https://www.linkedin.com/help/linkedin/answer/a548372 + +- Maximum file size: 5GB +- Minimum file size: 75KB +- Maximum video duration: 15 minutes when uploading from desktop and 10 minutes when uploading from the LinkedIn mobile app. +- Minimum video duration: 3 seconds +- Resolution range: 256x144 to 4096x2304 +- Aspect ratio: 1:2.4 - 2.4:1 +- Frame rates: 10fps - 60 fps +- Bit rates: 192 kbps - 30 Mbps + +# Random documentation + +## access token +https://www.linkedin.com/advice/1/how-do-you-use-refresh-tokens-different-types-oauth-20-clients +client credentials flow + +https://learn.microsoft.com/en-us/linkedin/shared/authentication/client-credentials-flow?context=linkedin%2Fcontext + +require scopes (r_liteprofile, w_member_social) + +https://stackoverflow.com/a/65652798/95733 +You need to select an enterprise product, like the Marketing Developer Platform. Go to your app and request access to this product. Your app needs to be reviewed first so this may take some time. + +## refresh tokens + +https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens + +LinkedIn supports programmatic refresh tokens for all approved Marketing Developer Platform (MDP) partners. +By default, access tokens are valid for 60 days and programmatic refresh tokens are valid for a year. + +## post api (legacy, detailed) + +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api?view=li-lms-unversioned&tabs=http + +## restliClient + +https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts + +## simple text post +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/posts-api?view=li-lms-2023-11&tabs=http#create-a-post + +## image + +### single image +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/posts-api?view=li-lms-2023-10&tabs=http#single-post-creation-sample-request + +get a lease +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/images-api?view=li-lms-2023-10&tabs=http#sample-request + +upload image +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/vector-asset-api?view=li-lms-2023-10&tabs=http#upload-the-image + +create a post +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/posts-api?view=li-lms-2023-10&tabs=http#single-post-creation-sample-request + +### multiple images + +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/multiimage-post-api?view=li-lms-2023-10&tabs=http#create-multiimage-content + +get a lease +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/images-api?view=li-lms-2023-10&tabs=http#sample-request + +upload image +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/vector-asset-api?view=li-lms-2023-10&tabs=http#upload-the-image + +create post +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/multiimage-post-api?view=li-lms-2023-10&tabs=http#sample-request + + +## video + +https://jcergolj.me.uk/publish-linkedin-post-with-video/ (lgc) + +get a lease +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#initialize-video-upload +OR +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/vector-asset-api?view=li-lms-2023-10&tabs=http#register-an-upload-for-video + +upload video +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#upload-the-video + +finalize upload +https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#finalize-video-upload + + + + diff --git a/docs/MultipleUsers.md b/docs/MultipleUsers.md new file mode 100644 index 0000000..4b8fe2b --- /dev/null +++ b/docs/MultipleUsers.md @@ -0,0 +1,24 @@ +# Set up for multiple users + +For each feed/page/channel/etc, Fairpost uses one 'user' that has it's own configuration, logging and feed folder. +If you don't specify a user, Fairpost assumes it's 'admin'. The admin user has no homedir and uses global config, logging and storage. + + +## User storage + +The global .env stores global (app) settings. + +The other stores, like access tokens, will be stored in the user directory +in the path specified in the global .env + +The users storage can override some things from the global config, but likely, you only want +to enter the feed/page/channel/etc settings for each platform. The rest is set globally. + +## User logging + +When command are called on behalf of a user, `./log4js.json` uses the +'user' category instead of the default category. It logs in a dedicated +logfile for the user, but also append some messages to the global log. + +The user could also host it's own log4js.json; settings there would +completely override the settings in the global log4j.json. diff --git a/docs/NewPlatform.md b/docs/NewPlatform.md new file mode 100644 index 0000000..02ad42c --- /dev/null +++ b/docs/NewPlatform.md @@ -0,0 +1,331 @@ +# How to add a new platform + +If your platform is not yet supported by Fairpost, +you can write your own code to support it. + +The hardest part is possibly registering your +instance of the app with the platform, to allow +it to post on your (or your users) behalf. How +that works depends on the platform; ymmv. + +## Minimal setup + +To add support for a new platform, add a class to `src/platforms` +extending `src/classes/Platform`. You want to override at least the +method `preparePost(source)` and `publishPost(post,dryrun)`. + +Make sure not to throw errors in or below publishPost; instead, just +return false and let the `Post.processResult()` itself. + +```php + { + const post = await super.preparePost(source); + if (post) { + // prepare your post here + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + + let response = { id: "-99" } as { id: string }; + let error = undefined as Error | undefined; + + try { + response = await this.publishFooBarPost(post, dryrun); + } catch (e) { + error = e as Error; + } + + return post.processResult( + response.id, + "https://url-to-your-post", + { + date: new Date(), + dryrun: dryrun, + success: !error, + response: response, + error: error, + }, + ); + } + + async publishFooBarPost(post: Post, dryrun: boolean = false): object { + return { + id: "-99", + error: "not implemented" + } + } +} + +``` + +Then in `src/platforms/index.ts` +- import your class +- add `PlatformId.FOOBAR` for your platform [^1] + +Then in your users `storage.json`, enable your platformId +``` +PLATFORMS=foobar,..,.. +``` + +check if it works: +``` +npm run lint:fix # optional +npm run build +./fairpost.js get-platforms +``` + +and party. + +### Add more methods + +#### FooBar.test() + +This method allows you to call `fairpost.js test-platform --platform=foobar`. +You can return anything. + +#### FooBar.connect() + +This method allows you to call `fairpost.js connect-platform --platform=foobar`, +usually to get the access tokens and save them in Storage. + +#### FooBar.refresh() + +This method allows you to call `fairpost.js refresh-platform --platform=foobar`, +usually to refresh the access tokens and save them in Storage. + +### Using User.get() and User.set() + +Your platform is constructed with a User, `FooBar.user`. +All configuration, including 'global' configuration from +Fairpost, is set on (and some can be overridden by) the user. +The user has four stores, `app`, `settings`,`auth` and `cache`. +Depending on the apps configuration, these may be stored in +different places. If a storage uses `.env`, it is read-only. + +```php + { + + return await fetch(url, { + method: "GET", + headers: { + Bla: 'Bla' + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFooBarError(err)) + .catch((err) => handleApiError(err,this.user)); + +... + + private async handleFooBarError(error: ApiResponseError): Promise { + error.message += '; FooBar made a booboo' + throw error; + } + +``` + +### FooBarAuth.ts + + +Another good approach to refactor is to take the Authentication +flow out of your platform into a separate `FooBar/FooBarAuth.ts`. +Add a method `connect()` and link your `Foobar.connect()` there. +Optionally add a method `refresh()` and link your `Foobar.refresh()` there. +Store the access tokens in `auth` Storage, so you can access them +in your platform class. + +There is a service to help you with the OAuth flow. It starts a web server +and presents you with a link to click, and processes the response: + +```php + { + const clientId = this.user.data.get("settings", "FOOBAR_CLIENT_ID"); + const clientHost = this.user.data.get("settings", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("settings", "OAUTH_PORT")); + const state = String(Math.random()).substring(2); + + // create auth url + const url = new URL("https://foobar.com"); + url.pathname = "bla/auth"; + const query = { + client_id: clientId, + redirect_uri: OAuth2Service.getCallbackUrl(clientHost,clientPort), + state: state, + response_type: "code", + scope: [ + "foo", + "bar" + ].join(" "), + }; + url.search = new URLSearchParams(query).toString(); + + const result = await OAuth2Service.requestRemotePermissions( + "FooBar", + url.href, + clientHost, + clientPort + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw Logger.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw Logger.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw Logger.error(msg, result); + } + return result["code"] as string; + } + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @returns - TokenResponse + */ + private async exchangeCode(code: string) { + const clientHost = this.user.data.get("settings", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("settings", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost,clientPort); + // implement your own post method ... + const tokens = (await this.post("token", { + grant_type: "authorization_code", + code: code, + client_id: this.user.data.get("settings", "FOOBAR_CLIENT_ID"), + client_secret: this.user.data.get("settings", "FOOBAR_CLIENT_SECRET"), + redirect_uri: redirectUri, + })); + if (!('accessToken' in tokens)) { + throw Logger.error("Invalid TokenResponse", tokens); + } + + return tokens; + } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + private async store(tokens) { + this.user.data.set("auth", "FOOBAR_ACCESS_TOKEN", tokens["access_token"]); + await this.user.data.save(); + } + +} + +``` + +[^1]: By default, the platform id is the classname to lowercase. You can override this implementing a static method `id()` that returns another value. \ No newline at end of file diff --git a/docs/Plugins.md b/docs/Plugins.md new file mode 100644 index 0000000..80b0a0c --- /dev/null +++ b/docs/Plugins.md @@ -0,0 +1,86 @@ +# Plugins + +Fairpost hosts a few plugins to prepare your Post +for your Platform. Such plugins can f.e. scale +images and/or remove certain files from a post +before it is scheduled and published. + +All plugins have an `id` and `settings` and +a static `defaults`. The format of the +defaults/settings differs per plugin. + +It's the platform source that defines the required +plugins and its default settings; some platform may +allow a user to add more plugins and/or change the +settings. + +## Calling a plugin + +To have a plugin process a post, simply create the +plugin with optionally its settings, and call the +`process` method. The example below will scale all +images in your post to have a maximum of 300px width, +maintaining the ratio, and limit it to 3 images: + +```php + { + // do stuff how many times. + // no need to save the post. + } + ``` + + Once you created the plugin, you can use it in all + platforms that allow you to manage plugins, and/or + in a new platform you're implementing. \ No newline at end of file diff --git a/docs/Reddit.md b/docs/Reddit.md new file mode 100644 index 0000000..9552e4a --- /dev/null +++ b/docs/Reddit.md @@ -0,0 +1,103 @@ +# Platform: Reddit + +## Set up the platform + +### Create a new App in your Reddit account + +- go to https://www.reddit.com/prefs/apps +- create an app ('script') + - redirect url with host/port from your .env (http://localhost:8000/callback) +- note the code and secret in your app box + - save as `FAIRPOST_REDDIT_CLIENT_ID` in your global .env + - save as `FAIRPOST_REDDIT_CLIENT_SECRET` in your global .env +- read the terms here https://www.reddit.com/wiki/api/#wiki_read_the_full_api_terms_and_sign_up_for_usage +- request access to the API using the request form, and wait until approved + +## Connect the platform to a user + +### Enable the platform + - Add 'reddit' to your `FAIRPOST_FEED_PLATFORMS` in your users `storage.json` + +### Get an OAuth2 Access Token for your Reddit account + +This token only lasts for 24 hours and should be refreshed. + + - call `./fairpost.js @userid connect-platform --platform=reddit` + - follow instructions from the command line + + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=reddit` + +## More user settings + + - `REDDIT_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + +# Random documentation + +https://www.reddit.com/r/test/ + +https://www.reddit.com/prefs/apps + +https://www.reddit.com/wiki/api/#wiki_read_the_full_api_terms_and_sign_up_for_usage + +duration permanent -> refresh +scope submit + +https://github.com/reddit-archive/reddit/wiki/OAuth2 + +GET https://www.reddit.com/api/v1/authorize?client_id=CLIENT_ID&response_type=TYPE& + state=RANDOM_STRING&redirect_uri=URI&duration=DURATION&scope=SCOPE_STRING + +POST https://www.reddit.com/api/v1/access_token + grant_type=authorization_code&code=CODE&redirect_uri=URI + Authorization: Basic Auth (client_id:client_secret) +{ + "access_token": Your access token, + "token_type": "bearer", + "expires_in": Unix Epoch Seconds, + "scope": A scope string, + "refresh_token": Your refresh token +} + +POST https://www.reddit.com/api/v1/access_token + grant_type=refresh_token&refresh_token=TOKEN + Authorization: Basic Auth (client_id:client_secret) + + +https://www.reddit.com/dev/api + +https://www.reddit.com/dev/api/oauth#POST_api_submit + + +~~ https://github.com/reddit-archive/reddit/wiki/OAuth2 + +https://www.reddit.com/r/redditdev/comments/9li6le/reddit_api_how_do_i_authenticate_trying_to_do/ + + +https://www.reddit.com/r/redditdev/comments/x53h1y/having_trouble_submitting_an_image_post/ + +https://github.com/rvelasq/scriptable-selig + +https://github.com/rvelasq/scriptable-selig/blob/master/Selig.js#L855 + + +https://github.com/Pyprohly/reddit-api-doc-notes/blob/main/docs/api-reference/submission.rst#upload-media + +upload video + +https://creatomate.com/blog/how-to-use-ffmpeg-in-nodejs + +https://www.reddit.com/r/redditdev/comments/9x3a6c/comment/e9p9cet/?utm_source=share&utm_medium=web2x&context=3 +https://oauth.reddit.com/api/v2/image_upload_s3.json +similar to upload emoji +https://www.reddit.com/dev/api/#POST_api_widget_image_upload_s3 + + +https://github.com/praw-dev/praw/blob/master/praw/models/reddit/subreddit.py#L1699 +"upload_image": "r/{subreddit}/api/upload_sr_img", +data["img_type"] = "jpg" +files={"file": image} + + +https://www.npmjs.com/package/reddit-api-image-upload diff --git a/docs/TikTok.md b/docs/TikTok.md new file mode 100644 index 0000000..008f0a3 --- /dev/null +++ b/docs/TikTok.md @@ -0,0 +1,180 @@ + +# Platform: Tiktok + +Tiktok does not allow services to run for a single user. +This platform is not yet working. + + +## Set up the platform + +- sign up for a developer account https://developers.tiktok.com/apps/ +- create a personal app + - Add the Login Kit and Content Posting API product to your app + - for the desktop redirect uri, use the details from your .env (http://localhost:8000/callback) + - allow direct posting + - for privacy policy, use https://github.com/commonpike/fairpost/blob/develop/public/privacy-policy.md + - for terms of service, use https://github.com/commonpike/fairpost/blob/develop/public/terms-of-use.md +- wait +- get rejected + +# Limits + + +Video restrictions +Supported media formats + +MP4 (recommended) +WebM +MOV +Supported codecs + + +H.264 (recommended) +H.265 +VP8 +VP9 +Framerate restrictions + +Minimum of 23 FPS +Maximum of 60 FPS +Picture size restrictions + + +Minimum of 360 pixels for both height and width +Maximum of 4096 pixels for both height and width +Duration restrictions + + +All TikTok creators can post 3-minute videos, while some have access to post 5-minute or 10-minute videos. +The longest video a developer can send via the initialize Upload Video endpoint is 10 minutes. TikTok users may trim developer-sent videos inside the TikTok app to fit their accounts' actual maximum publish durations. +Size restrictions + +Maximum of 4GB +Image restrictions +Supported media formats + +WebP +JPEG +Picture size restrictions + +Maximum 1080p +Size restrictions + +Maximum of 20MB for each image + +# Random documentation + +https://developers.tiktok.com/doc/content-posting-api-get-started/ + +""" +a. API Clients should display a preview of the to-be-posted content. +c. API Clients must only start sending content materials to TikTok after the user has expressly consent to the upload. +""" + +video: +- get creator info (*) + - show nickname + - check max post and max_video_post_duration_sec +- do a video/init + - check privacy_level_options from creator info + - check Interaction Ability from creator info + - Users must manually turn on these interaction settings and none should be checked by default. +- post video to uploadurl + +"API clients should poll the publish/status/fetch API or handle status update webhooks, so users can understand the status of their posts." + +``` +curl --location --request POST 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' \ +--header 'Authorization: Bearer act.example12345Example12345Example' \ +--header 'Content-Type: application/json; charset=UTF-8' +``` + +``` +curl --location 'https://open.tiktokapis.com/v2/post/publish/video/init/' \ +--header 'Authorization: Bearer act.example12345Example12345Example' \ +--header 'Content-Type: application/json; charset=UTF-8' \ +--data-raw '{ + "post_info": { + "title": "this will be a funny #cat video on your @tiktok #fyp", + "privacy_level": "MUTUAL_FOLLOW_FRIENDS", + "disable_duet": false, + "disable_comment": true, + "disable_stitch": false, + "video_cover_timestamp_ms": 1000 + }, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": 50000123, + "chunk_size": 10000000, + "total_chunk_count": 5 + } +}' + +... + +curl --location --request PUT 'https://open-upload.tiktokapis.com/video/?upload_id=67890&upload_token=Xza123' \ +--header 'Content-Range: bytes 0-30567099/30567100' \ +--header 'Content-Type: video/mp4' \ +--data '@/path/to/file/example.mp4' + +... + +curl --location 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' \ +--header 'Authorization: Bearer act.example12345Example12345Example' \ +--header 'Content-Type: application/json; charset=UTF-8' \ +--data '{ + "publish_id": "v_pub_url~v2.123456789" +}' + +``` + +for chunking: +https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide/ + +photo: +- just upload + +``` +curl --location 'https://open.tiktokapis.com/v2/post/publish/content/init/' \ +--header 'Authorization: Bearer act.example12345Example12345Example' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "post_info": { + "title": "funny cat", + "description": "this will be a #funny photo on your @tiktok #fyp", + "disable_comment": true, + "privacy_level": "PUBLIC_TO_EVERYONE", + "auto_add_music": true + }, + "source_info": { + "source": "PULL_FROM_URL", + "photo_cover_index": 1, + "photo_images": [ + "https://tiktokcdn.com/obj/example-image-01.webp", + "https://tiktokcdn.com/obj/example-image-02.webp" + ] + }, + "post_mode": "DIRECT_POST", + "media_type": "PHOTO" +}' + +``` + +https://developers.tiktok.com/doc/content-sharing-guidelines + +1) API Clients must retrieve the latest creator info when rendering the Post to TikTok page. + +a. The upload page must display the creator's nickname, so users are aware of which TikTok account the content will be uploaded to. + +b. When the creator_info API returns that the creator can not make more posts at this moment, API Clients must stop the current publishing attempt and prompt users to try again later. + +c. When posting a video, API clients must check if the duration of the to-be-posted video follows the max_video_post_duration_sec returned in the creator_info API. + + +Content Disclosure Setting must be "Your Brand" or "Branded Content" + +If a user wants to choose Branded Content, it is important to note that it can only be configured with visibility as public/friends. + +When only "Your Brand" is checked, the declaration should be the same as mentioned above: "By posting, you agree to TikTok's Music Usage Confirmation." +When only "Branded Content" is checked, the declaration should be changed to: "By posting, you agree to TikTok's Branded Content Policy and Music Usage Confirmation." +Additionally, when both options are selected, the declaration should be: "By posting, you agree to TikTok's Branded Content Policy and Music Usage Confirmation." \ No newline at end of file diff --git a/docs/Twitter.md b/docs/Twitter.md new file mode 100644 index 0000000..9a0874c --- /dev/null +++ b/docs/Twitter.md @@ -0,0 +1,83 @@ +# Platform: Twitter + +The Twitter platform is using +https://github.com/PLhery/node-twitter-api-v2 + + +## Set up the platform + +The Twitter api was being rebuild when Elon Musk +bought it and broke it. Part of it now runs on +OAuth1, and part of it on Oauth2, and you have +to configure both. Hopefully, one day, the 'O1' +keys will not be needed anymore. + +### Create a new App in your twitter account + +- go to https://developer.twitter.com/ +- create a few developer account +- you get a project and an app, rename those +- set up User authentication settings + - read and write + - fairpost is a bot + - redirect url with host/port from your .env (http://localhost:8000/callback) + - website https://github.com/commonpike/fairpost +- From the Oauth 01 settings + - generate Api Key and secret + - save these in your global .env as + - `FAIRPOST_TWITTER_OA1_API_KEY` + - `FAIRPOST_TWITTER_OA1_API_KEY_SECRET` + - generate access token and secret, make sure it is read and write + - save these in your global .env as as + - `FAIRPOST_TWITTER_OA1_ACCESS_TOKEN` + - `FAIRPOST_TWITTER_OA1_ACCESS_TOKEN_SECRET` +- From the OAuth 2 settings + - save `FAIRPOST_TWITTER_CLIENT_ID` in your global .env + - save `FAIRPOST_TWITTER_CLIENT_SECRET` in your global .env + +## Connect the platform to a user + +### Enable the platform + - Add 'twitter' to your `FEED_PLATFORMS` in your users `storage.json` + +### Get an OAuth2 Access Token for your twitter account + +This token should last forever (?) + + - call `./fairpost.js @userid connect-platform --platform=twitter` + - follow instructions from the command line + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=twitter` + +## Connect the platform to another user + +One fairpost user can only manage one feed. If you create a second user, you can use the same app to manage a different feed. OAuth2 allows you to enable the app for your second account, but the OAuth1 part is tied to your first +account and requires you to specify an 'additional_owner' for the uploaded media. + +### Add a second user +- call `./fairpost.js create-user --userid=foo` + +### Get an OAuth2 Access Token for your other page + +- call `./fairpost.js @foo connect-platform --platform=twitter` +- follow instructions from the command line + +### Test the other installation +- call `./fairpost.js @foo test-platform --platform=twitter` + + +### Set the 'additional owner' +- from the previous `test-platform` result, copy the `oauth2:id` +- set this as the `TWITTER_OA1_ADDITIONAL_OWNER` in your users storage.json + + +## More user settings + +- `TWITTER_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + +# Random documentation + +https://github.com/twitterdev/twitter-api-typescript-sdk/blob/main/src/gen/Client.ts#L889 + +https://github.com/twitterdev/Twitter-API-v2-sample-code/blob/main/Manage-Tweets/create_tweet.js#L106 \ No newline at end of file diff --git a/docs/Youtube.md b/docs/Youtube.md new file mode 100644 index 0000000..2007af5 --- /dev/null +++ b/docs/Youtube.md @@ -0,0 +1,141 @@ +# Platform: YouTube + +The `youtube` platform manages a youtube **channel** +using `@googleapis/youtube` and `google-auth-library`. + +To upload public videos, your app needs to be verified / audited first. + +By using Fairpost on YouTube, you are agreeing to be bound by +the YouTube Terms of Service: https://www.youtube.com/t/terms + +Your posts will be preprocessed to fit YouTube. The limitations +imposed by Fairpost are not imposed by YouTube. + + +## Set up the platform + + +### Create a new project in your account + +Google has a wizard to create a youtube app: https://console.developers.google.com/start/api?id=youtube +Below is how to do it manually. + + - Log in to Google Developers Console: https://console.cloud.google.com/cloud-resource-manager + - Create a new project. + - set it to external, testing. only test users can use it + - Go to the project dashboard, currently at https://console.cloud.google.com/home/dashboard?project={yourproject} + - click Explore & Enable APIs. + - In the library, navigate to YouTube Data API v3 under YouTube APIs. + - enable that + - Create an OAuth consent screen + - website https://github.com/commonpike/fairpost + - privacy https://github.com/commonpike/fairpost/blob/develop/public/privacy-policy.md + - for the scopes, add YouTube Data API v3 + - Under credentials, create OAuth 2.0 Client IDs + - Save as `FAIRPOST_YOUTUBE_CLIENT_ID` and `FAIRPOST_YOUTUBE_CLIENT_SECRET` in your global .env + +### Get your app audited + +You can already proceed below to test the app for private videoos. +To have Fairpost publish **public** videos, your app has to be audited + + - go to https://support.google.com/youtube/contact/yt_api_form + - request an audit + - For the website, link to https://github.com/commonpike/fairpost + - For the 'document describing your implementation', post this file + - wait. + +## Connect the platform to a user + +### Enable the platform + - Add 'youtube' to your `FEED_PLATFORMS` in your users `storage.json` + +### Get an OAuth2 Access Token for your platform + +This token last for a few hours and should be refreshed. +The refresh token (if given) lasts until it is revoked. + + - call `./fairpost.js @userid connect-platform --platform=youtube` + - follow instructions from the command line + +### Test the platform + - call `./fairpost.js @userid test-platform --platform=youtube` + + + +## Connect the platform to another user + +- call `./fairpost.js create-user --user=foo` +- add youtube to its FAIRPOST_PLATFORMS + +### Get an OAuth2 Access Token for your other page + + - call `./fairpost.js @foo connect-platform --platform=youtube` + - follow instructions from the command line + +### Test the other installation + - call `./fairpost.js @foo test-platform --platform=youtube` + +## More user settings + +- `YOUTUBE_PRIVACY` = public | private | unlisted +- `YOUTUBE_CATEGORY` = valid youtube category id +- `YOUTUBE_PLUGIN_SETTINGS` - a json object describing / overwriting the plugins used to prepare posts + +# Limitations + +## Refresh token + +As long as your app is in testing status, Refresh Tokens will expire every +7 days - https://stackoverflow.com/a/69142542/95733 + +## Video + +### Supported Formats +Accepted Media MIME types: +video/*, application/octet-stream + +### File Size +Maximum file size: 256GB + + +# Random documentation + +https://developers.google.com/youtube/v3 + +https://developers.google.com/youtube/v3/docs/videos/insert + +https://developers.google.com/youtube/v3/docs/videos#resource + +https://developers.google.com/youtube/v3/guides/auth/installed-apps#chrome + +https://blog.hubspot.com/website/how-to-get-youtube-api-key + +scopes +https://www.googleapis.com/auth/youtube.force-ssl +https://www.googleapis.com/auth/youtube.readonly +https://www.googleapis.com/auth/youtube.upload + +https://googleapis.dev/nodejs/googleapis/latest/slides/ + +https://pixelswap.fr/entry/how-to-upload-a-video-on-youtube-with-nodejs/ + +https://stackoverflow.com/questions/65258438/how-to-upload-video-to-youtube-using-google-api-without-libraries + +https://developers.google.com/youtube/terms/required-minimum-functionality + + +https://cloud.google.com/nodejs/docs/reference/google-auth-library/latest/google-auth-library/oauth2client + +refreshAccessToken(callback) +refreshToken(refreshToken) +refreshTokenNoCache(refreshToken) +getAccessToken() +isTokenExpiring() + + +https://googleapis.dev/nodejs/google-auth-library/9.8.0/#oauth2 + +https://google-auth.readthedocs.io/en/stable/reference/google.oauth2.credentials.html +https://googleapis.dev/nodejs/google-auth-library/8.5.2/interfaces/Credentials.html +https://googleapis.dev/nodejs/google-auth-library/8.5.2/interfaces/GetAccessTokenResponse.html \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f2f8ddf --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,40 @@ +import jsdoc from 'eslint-plugin-jsdoc'; +import prettier from 'eslint-plugin-prettier'; +import typescript from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + }, + }, + plugins: { + '@typescript-eslint': typescript, + prettier, + jsdoc, + }, + rules: { + ...typescript.configs.recommended.rules, + ...prettier.configs.recommended.rules, + ...jsdoc.configs.recommended.rules, + "prettier/prettier": 'error', + "jsdoc/require-jsdoc": 'off', + "jsdoc/require-param-description" : 'off', + "jsdoc/require-param-type" : 'off', + "jsdoc/require-returns-type" : 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-empty-object-type': ['warn', { allowWithName: '.*Dto' }], + "@typescript-eslint/no-floating-promises": "error", + }, + + }, + { + ignores: ['node_modules/', 'dist/', 'users/', 'build/'], + } +]; \ No newline at end of file diff --git a/etc/skeleton/README.md b/etc/skeleton/README.md new file mode 100644 index 0000000..56edb07 --- /dev/null +++ b/etc/skeleton/README.md @@ -0,0 +1,10 @@ +# User folder + +By default this folder is also used to store +the staging folders (incoming,pending,etc). +That path can be defined in your `.env` file. + +Post your source posts in 'incoming', each in a separate +folder. Folder names starting with an underscore are ignored. +When processed, the folder names will be prepended +with something unique. \ No newline at end of file diff --git a/etc/skeleton/incoming/.gitkeep b/etc/skeleton/incoming/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etc/skeleton/storage.json b/etc/skeleton/storage.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/etc/skeleton/storage.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fairpost.js b/fairpost.js new file mode 100755 index 0000000..a594aba --- /dev/null +++ b/fairpost.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "./build/cli.js"; \ No newline at end of file diff --git a/index-org.ts b/index-org.ts deleted file mode 100644 index 560db9d..0000000 --- a/index-org.ts +++ /dev/null @@ -1,542 +0,0 @@ -/* - Usage - - node index.js - --dry-run - --debug - --platforms=x[,y,..]|all - --date=yyyy-mm-dd|now - --posts=dir1[,dir2,..] - --max=x - --interval=7 - --types=[text,images,video]|auto - -*/ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as sharp from 'sharp'; - -import { randomUUID } from 'crypto'; -import * as dotenv from 'dotenv' -dotenv.config() - -// constants -const APP_TITLE = 'Ayrshare feed'; -const APIKEY = process.env.AYRSHARE_API_KEY; -const FEED_PATH = process.env.AYRSHARE_FEEDPATH; -const SUPPORTED_PLATFORMS = process.env.AYRSHARE_PLATFORMS?.split(',')??[]; -const REDDIT_SUBREDDIT = process.env.AYRSHARE_SUBREDDIT; - -// arguments -const DRY_RUN = !!getArgument('dry-run') ?? false; -const DEBUG = !!getArgument('debug') ?? false; -const REQUESTED_PLATFORMS = (getArgument('platforms') as string)?.split(',') ?? SUPPORTED_PLATFORMS; -const POST_DIRS = (getArgument('posts') as string)?.split(',') ?? []; -const POST_MAX = getArgument('max')?Number(getArgument('max')):0; -const FIRST_POSTDATE = getArgument('date')?new Date(getArgument('date') as string):null; -const POSTDATE_INTERVAL = getArgument('interval')?Number(getArgument('interval')):Number(process.env.AYRSHARE_INTERVAL); -const REQUESTED_TYPES = (getArgument('types') as string)?.split(',') ?? ['text','image','video']; - -// interfaces -interface PostResult { - dryrun?: boolean; - status: string; - error?: Error; - media: string[]; - platforms: string[]; -} - -interface PostData { - type: 'image'|'video'|'text'; - platforms : string[]; - title: string; - body : string; - images : string[]; - videos : string[]; - media : { - [platform: string]: string[]; - }; - scheduled : Date; - posted : Date; - pending: boolean; - results : { - [platform: string]: PostResult; - }; -} - -// Classes - -class Post { - - static defBody = "#Ayrshare feed"; - static postFile = 'post.json'; - - static requiresApproval = false; - - static dryRun = DRY_RUN; - static debug = DEBUG; - static supported = SUPPORTED_PLATFORMS; - static postDateInterval = POSTDATE_INTERVAL; - - static nextPostDate = new Date(); - static type2platforms = { - video: ["facebook", "youtube","instagram","linkedin","tiktok"], - image: ["twitter", "facebook", "instagram","linkedin","reddit"], - text: ["twitter", "facebook","linkedin","reddit"] - }; - - - path = ''; - data = undefined as PostData || undefined; - - constructor(path: string) { - this.path = path; - const files = this.getFiles(); - if (files.includes(Post.postFile)) { - const data = JSON.parse(fs.readFileSync(this.path+'/'+Post.postFile, 'utf8')); - if (data) { - this.data = data; - this.data.scheduled= data?.scheduled?new Date(data.scheduled):undefined; - this.data.posted = data?.posted?new Date(data.posted):undefined; - } - } - if (!this.data) { - this.data = { - type: 'text', - platforms : [], - title: '', - body : '#Ayrshare feed', - images : [], - videos : [], - media: {}, - scheduled : undefined, - posted : undefined, - pending: true, - results : {} - }; - } - if (!this.data.media) { - this.data.media = {}; - } - try { - this.data.body = fs.readFileSync(path+'/body.txt','utf8'); - } catch (e) { - this.data.body = Post.defBody; - } - this.data.title = this.data.body.split('\n', 1)[0]; - // TBD config extensions - this.data.images = files.filter(file=>["jpg","jpeg","png"].includes(file.split('.')?.pop()??'')); - this.data.videos = files.filter(file=>["mp4"].includes(file.split('.')?.pop()??'')); - // TBD throw errors for mixed types - if (this.data.images.length) { - this.data.type="image"; - } else if (this.data.videos.length) { - this.data.type="video"; - } else { - this.data.type="text"; - } - - this.data.platforms = Post.type2platforms[this.data.type] - .filter(platform => Post.supported.includes(platform)); - - this.write(); - } - - write() { - if (Post.debug) { - console.log(this.getJson()); - } - if (!Post.dryRun) { - fs.writeFileSync(this.path+'/'+Post.postFile,this.getJson()); - } - } - getJson() { - return JSON.stringify(this.data,null,"\t"); - } - - getFiles(): string[] { - return fs.readdirSync(this.path).filter(file => { - return !file.startsWith('_') && fs.statSync(this.path+'/'+file).isFile(); - }); - } - - async process(requestedPlatforms?: string[]) { - const processedPlatforms = Object.values(this.data.results) - .filter(r=>r.status==='success').flatMap(r=>r.platforms); - const pendingPlatforms = this.data.platforms - .filter(platform => !processedPlatforms.includes(platform)); - const processPlatforms = requestedPlatforms ? - pendingPlatforms.filter( - platform => requestedPlatforms.includes(platform) - ): pendingPlatforms; - if (processPlatforms.length) { - console.log('+ scheduling post '+this.path,Post.nextPostDate,processPlatforms); - await this.schedule(processPlatforms); - } else { - console.log('v post '+this.path+' already scheduled at '+this.data.scheduled); - } - } - - async schedule(platforms: string[]) { - - this.data.scheduled = new Date(Post.nextPostDate); - - switch (this.data.type) { - case "image": - await this.prepareImagePost(platforms); - break; - case "video": - await this.prepareVideoPost(platforms); - break; - default: - await this.prepareTextPost(platforms); - } - - const results = await this.ayrshare(platforms); - - if (Object.values(results).map(r=>r.status).includes('success') || - Object.values(results).map(r=>r.status).includes('scheduled')) { - this.data.posted=new Date(); - Post.nextPostDate.setDate(Post.nextPostDate.getDate()+Post.postDateInterval); - } else { - this.data.posted=undefined; - this.data.scheduled=undefined; - if (Post.dryRun) { - Post.nextPostDate.setDate(Post.nextPostDate.getDate()+Post.postDateInterval); - } - } - if (Object.values(results).every(r=>r.status==='success' || r.status==='scheduled')) { - this.data.pending=false; - } - - for (const platform in results) { - this.data.results[platform] = results[platform]; - } - - this.write(); - } - - async prepareTextPost(platforms: string[]) { - return; - } - - async prepareVideoPost(platforms: string[]) { - - const media = await this.uploadMedia(this.data.videos); - - // linkedin: max 9 media - if (platforms.includes('linkedin') && media.length>9) { - this.data.media['linkedin'] = media.slice(0, 9); - } - // reddit: max 1 media - //if (platforms.includes('reddit') && media.length>1) { - // this.data.media['reddit'] = media.slice(0, 1); - //} - // tiktok: max len 60s - // ... - - // rest - this.data.media['default']=media; - - } - - async prepareImagePost(platforms: string[]) { - - // insta: max 1440 wide - if (platforms.includes('instagram')) { - const originalImages = this.data.images; - const resizedImages = [] as string[]; - let haveResized=false; - for (const image of originalImages) { - const metadata = await sharp(this.path+'/'+image).metadata(); - if (metadata.width > 1440) { - console.log('Resizing '+image+' for instagram ..'); - await sharp(this.path+'/'+image).resize({ width: 1440 }).toFile(this.path+'/_instagram-'+image); - resizedImages.push('_instagram-'+image); - haveResized=true; - } else { - resizedImages.push(image); - } - } - if (haveResized) { - this.data.media['instagram'] = await this.uploadMedia(resizedImages); - } - } - const media = await this.uploadMedia(this.data.images); - - // twitter: max 4 media - if (platforms.includes('twitter') && media.length>4) { - this.data.media['twitter'] = media.slice(0, 4); - } - // reddit: max 1 media - if (platforms.includes('reddit') && media.length>1) { - this.data.media['reddit'] = media.slice(0, 1); - } - // linkedin: max 9 media - if (platforms.includes('linkedin') && media.length>9) { - this.data.media['linkedin'] = media.slice(0, 9); - } - - // rest - this.data.media['default']=media; - - } - - - - async uploadMedia(media: string[]): Promise { - const urls= [] as string[]; - for (const file of media) { - const buffer = fs.readFileSync(this.path+'/'+file); - const ext = path.extname(file); - const basename = path.basename(file, ext); - const uname = basename+'-'+randomUUID()+ext; - console.log('fetching uploadid...',this.path+'/'+file); - const res1 = await fetch("https://app.ayrshare.com/api/media/uploadUrl?fileName="+uname+"&contentType="+ext.substring(1), { - method: "GET", - headers: { - "Authorization": `Bearer ${APIKEY}` - } - }); - - if (!res1) { - return []; - } - - const data = await res1.json(); - //console.log(data); - console.log('uploading..',uname); - const uploadUrl = data.uploadUrl; - const contentType = data.contentType; - const accessUrl = data.accessUrl; - - const res2 = await fetch(uploadUrl, { - method: "PUT", - headers: { - "Content-Type": contentType, - "Authorization": `Bearer ${APIKEY}` - }, - body: buffer, - }); - - if (!res2) { - return []; - } - - urls.push(accessUrl.replace(/ /g, '%20')); - - } - return urls; - } - - async ayrshare(platforms: string[]): Promise<{ - [platform:string] : PostResult - }> { - - if (!platforms.length) { - return {}; - } - const result = {} as { - [platform:string] : PostResult - }; - const mediaPlatforms = Object.keys(this.data.media); - - for (const mediaPlatform in this.data.media) { - - let error = undefined as Error | undefined; - const media = this.data.media[mediaPlatform]; - - const postPlatforms = mediaPlatform==='default'? - platforms.filter(p=>!mediaPlatforms.includes(p)): - (platforms.includes(mediaPlatform))?[mediaPlatform]:[]; - - if (!postPlatforms.length) { - continue; - } - if (Post.dryRun) { - result[mediaPlatform] = { - dryrun:true, - status: 'dryrun', - platforms:postPlatforms, - media:media - }; - } else { - const body = JSON.stringify(media.length?{ - post: this.data.body, // required - platforms: postPlatforms, // required - mediaUrls: media, - scheduleDate: this.data.scheduled, - requiresApproval: Post.requiresApproval, - isVideo: (this.data.type==='video'), - youTubeOptions: { - title: this.data.title, // required max 100 - visibility: "public" // optional def private - }, - instagramOptions: { - // "autoResize": true -- only enterprise plans - }, - redditOptions: { - title: this.data.title, // required - subreddit: REDDIT_SUBREDDIT, // required (no "/r/" needed) - } - }:{ - post: this.data.body, // required - platforms: this.data.platforms, // required - scheduleDate: this.data.scheduled, - requiresApproval: Post.requiresApproval - }); - console.log('scheduling...',mediaPlatform); - //console.log(body); - const res = await fetch("https://app.ayrshare.com/api/post", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${APIKEY}` - }, - body: body - }).catch(e=> { - error = e; - }); - - if (res && res.ok) { - //console.log(res.json()); - result[mediaPlatform] = await res.json() as unknown as PostResult; - if (result[mediaPlatform]['status']!=='success' && result[mediaPlatform]['status']!=='scheduled') { - console.error(result); - } else { - console.log(result); - } - result[mediaPlatform]['dryrun'] = false; - result[mediaPlatform]['platforms'] = postPlatforms; - result[mediaPlatform]['media'] = media; - } else { - console.error(res); - error = new Error('no result'); - } - if (error!==undefined) { - console.error(error); - result[mediaPlatform]={ - dryrun:false, - status: 'error', - error: error, - platforms: postPlatforms, - media:media - }; - } - } - } - return result; - } -} - -class Feed { - path = ''; - constructor(path: string) { - this.path = path; - } - getDirectories(path: string): string[] { - return fs.readdirSync(path).filter(function (file) { - return !file.startsWith('_') && fs.statSync(path+'/'+file).isDirectory(); - }); - } - getPosts(): Post[] { - const posts = [] as Post[]; - this.getDirectories(this.path).forEach(postDir=> { - const post = new Post(this.path+'/'+postDir); - posts.push(post); - }); - return posts; - } - getPendingPosts(): Post[] { - return this.getPosts().filter(p=>p.data.pending); - } - getNextPostDate(): Date { - const today = new Date(); - let lastPostDate = new Date('1970-01-01'); - - this.getPosts().forEach(post=>{ - if (post.data.scheduled && post.data.scheduled>lastPostDate) { - //console.log(post.scheduled,lastPostDate); - lastPostDate = new Date(post.data.scheduled); - } - }); - const nextPostDate = new Date(lastPostDate); - nextPostDate.setDate(nextPostDate.getDate()+Post.postDateInterval); - - console.log('Last post date',lastPostDate); - console.log('Next post date',nextPostDate); - - if (nextPostDate element.startsWith( `--${ key }=` ) ); - if ( !value ) return null; - return value.replace( `--${ key }=` , '' ); -} - - - -/* main */ -async function main() { - - console.log(APP_TITLE+' starting .. ',Post.dryRun?'dry-run':''); - console.log(); - - if (!fs.existsSync(FEED_PATH)) { - fs.mkdirSync(FEED_PATH); - } - - let posts = [] as Post[]; - if (!POST_DIRS.length) { - const feed = new Feed(FEED_PATH); - posts = feed.getPendingPosts(); - if (FIRST_POSTDATE) { - Post.nextPostDate = FIRST_POSTDATE; - } else { - Post.nextPostDate = feed.getNextPostDate(); - } - } else { - posts = POST_DIRS.map(d=>new Post(d)); - if (FIRST_POSTDATE) { - Post.nextPostDate = FIRST_POSTDATE; - } else { - Post.nextPostDate = new Date(); - } - } - - if (POST_MAX!==0) { - posts = posts.slice(0,POST_MAX); - } - - console.log(); - for (const post of posts) { - console.log('Found',post.path,post.data.type,'...'); - } - - for (const post of posts) { - console.log(); - if (REQUESTED_TYPES.includes(post.data.type)) { - console.log('Processing',post.path,post.data.type,'...'); - await post.process(REQUESTED_PLATFORMS); - } else { - console.log('Skipping',post.path,post.data.type,'...'); - } - console.log(); - } - - console.log(); - console.log(APP_TITLE+' All done',Post.dryRun?' (dry-run).':'.'); - -} - -main(); \ No newline at end of file diff --git a/index.ts b/index.ts deleted file mode 100644 index 4512219..0000000 --- a/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - Usage - - tsc && node build/fayrshare.js - node build/fayrshare.js .. - - -*/ - -import Feed from './src/classes/Feed'; -import { PostStatus } from './src/classes/Post'; -import { PlatformSlug } from './src/platforms'; - -// arguments -const COMMAND = process.argv[2] ?? 'help' - -// options -const DRY_RUN = !!getOption('dry-run') ?? false; -const CONFIG = (getOption('config') as string ) ?? '.env'; -const PLATFORMS = (getOption('platforms') as string)?.split(',') as PlatformSlug[] ?? undefined; -const FOLDERS = (getOption('folders') as string)?.split(',') ?? undefined; -const DATE = (getOption('date') as string) ?? undefined; -const STATUS = (getOption('status') as PostStatus) ?? undefined; - - -// utilities - -function getOption(key:string):boolean|string|null { - if ( process.argv.includes( `--${ key }` ) ) return true; - const value = process.argv.find( element => element.startsWith( `--${ key }=` ) ); - if ( !value ) return null; - return value.replace( `--${ key }=` , '' ); -} - - -/* main */ -async function main() { - - const feed = new Feed(CONFIG); - console.log('Fayrshare '+feed.path+' starting .. ',DRY_RUN?'dry-run':''); - console.log(); - - let result: any = ''; - switch(COMMAND) { - case 'get-feed': - result = feed; - break; - case 'get-folders': - result = feed.getFolders(FOLDERS); - break; - case 'get-posts': - result = feed.getPosts({ - paths:FOLDERS, - platforms:PLATFORMS, - status: STATUS - }); - break; - case 'prepare-posts': - result = await feed.preparePosts({ - paths:FOLDERS, - platforms:PLATFORMS - }); - break; - case 'schedule-next-posts': - result = feed.scheduleNextPosts(DATE ? new Date(DATE): undefined,{ - paths:FOLDERS, - platforms:PLATFORMS - }); - break; - case 'publish-due-posts': - result = await feed.publishDuePosts({ - paths:FOLDERS, - platforms:PLATFORMS - }, DRY_RUN); - break; - default: - const cmd = process.argv[1]; - console.log(` -${cmd} help -${cmd} get-feed [--config=xxx] -${cmd} get-folders [--folders=xxx,xxx] -${cmd} prepare-posts [--platforms=xxx,xxx] [--folders=xxx,xxx] -${cmd} get-posts [--status=xxx] [--platforms=xxx,xxx] [--folders=xxx,xxx] -${cmd} schedule-next-post [--date=xxxx-xx-xx] [--platforms=xxx,xxx] [--folders=xxx,xxx] -${cmd} publish-due-posts [--platforms=xxx,xxx] [--folders=xxx,xxx] [--dry-run] - `); - - } - console.log(JSON.stringify(result,null,'\t')); - - -} - -main(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e16753 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3847 @@ +{ + "name": "fairpost", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fairpost", + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "@atproto/api": "^0.15.23", + "@flystorage/file-storage": "^1.0.1", + "@flystorage/local-fs": "^1.0.0", + "@googleapis/youtube": "^20.0.0", + "argon2": "^0.41.1", + "cookie": "^1.0.2", + "dotenv": "^16.0.3", + "fast-xml-parser": "^4.3.2", + "google-auth-library": "^9.4.2", + "log4js": "^6.9.1", + "mime-types": "^2.1.35", + "node-fetch": "^3.3.2", + "sharp": "^0.33.5", + "twitter-api-v2": "^1.15.2" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-jsdoc": "^50.6.3", + "eslint-plugin-prettier": "^5.0.1", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "typedoc": "^0.28.4", + "typescript": "^5.0.4" + }, + "engines": { + "node": "22.17.0" + } + }, + "node_modules/@atproto/api": { + "version": "0.15.23", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.15.23.tgz", + "integrity": "sha512-qrXMPDs8xUugQyNxU5jm5xlhfx60SzOIzmHkZkI7ExYQFjX6juCabR9t8LofIUSiZKRY1PcU4QUFyhQIsjFuVg==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.2", + "@atproto/lexicon": "^0.4.11", + "@atproto/syntax": "^0.4.0", + "@atproto/xrpc": "^0.7.0", + "await-lock": "^2.2.2", + "multiformats": "^9.9.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz", + "integrity": "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==", + "license": "MIT", + "dependencies": { + "graphemer": "^1.4.0", + "multiformats": "^9.9.0", + "uint8arrays": "3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.11.tgz", + "integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.2", + "@atproto/syntax": "^0.4.0", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.0.tgz", + "integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==", + "license": "MIT" + }, + "node_modules/@atproto/xrpc": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.0.tgz", + "integrity": "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.4.11", + "zod": "^3.23.8" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", + "integrity": "sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", + "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", + "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", + "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.10.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@flystorage/dynamic-import": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@flystorage/dynamic-import/-/dynamic-import-1.0.0.tgz", + "integrity": "sha512-CIbIUrBdaPFyKnkVBaqzksvzNtsMSXITR/G/6zlil3MBnPFq2LX+X4Mv5p2XOmv/3OulFs/ff2SNb+5dc2Twtg==", + "license": "MIT" + }, + "node_modules/@flystorage/file-storage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@flystorage/file-storage/-/file-storage-1.0.1.tgz", + "integrity": "sha512-ditKPEsBIz2ZJ96870Kq0xydv76vTA3tyoMrKK7T9VI/DS7vYJ0HSrroDzKeO7cbZ4JcoywpQwwKdcG1YlEM+w==", + "license": "MIT" + }, + "node_modules/@flystorage/local-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@flystorage/local-fs/-/local-fs-1.0.0.tgz", + "integrity": "sha512-PHl/8dQv+aHQDiMtNzpzQmvGv/FL0ztLyXjmY69ymgHbYfjc6oGsWD9LZggC2qvMu3ntBKOojCnvn7mQK5n9wA==", + "license": "MIT", + "dependencies": { + "@flystorage/dynamic-import": "^1.0.0", + "@flystorage/file-storage": "^1.0.0", + "file-type": "^19.6.0" + }, + "engines": { + "node": ">=20.1.0" + } + }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.4.2.tgz", + "integrity": "sha512-3jXo5bNjvvimvdbIhKGfFxSnKCX+MA8wzHv55ptzk/cx8wOzT+BRcYgj8aFN3yTiTs+zvQQiaZFr7Jce1ZG3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.4.2", + "@shikijs/langs": "^3.4.2", + "@shikijs/themes": "^3.4.2", + "@shikijs/types": "^3.4.2", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@googleapis/youtube": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-20.0.0.tgz", + "integrity": "sha512-wdt1J0JoKYhvpoS2XIRHX0g/9ul/B0fQeeJAhuuBIdYINuuLt6/oZYZZCBmkuhtkA3IllXgqgAXOjLtLRAnR2g==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.4.2.tgz", + "integrity": "sha512-zcZKMnNndgRa3ORja6Iemsr3DrLtkX3cAF7lTJkdMB6v9alhlBsX9uNiCpqofNrXOvpA3h6lHcLJxgCIhVOU5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.4.2", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.4.2.tgz", + "integrity": "sha512-H6azIAM+OXD98yztIfs/KH5H4PU39t+SREhmM8LaNXyUrqj2mx+zVkr8MWYqjceSjDw9I1jawm1WdFqU806rMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.4.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.4.2.tgz", + "integrity": "sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.4.2" + } + }, + "node_modules/@shikijs/types": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.4.2.tgz", + "integrity": "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", + "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", + "integrity": "sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/type-utils": "8.24.0", + "@typescript-eslint/utils": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz", + "integrity": "sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/typescript-estree": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz", + "integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.0.tgz", + "integrity": "sha512-8fitJudrnY8aq0F1wMiPM1UUgiXQRJ5i8tFjq9kGfRajU+dbPyOuHbl0qRopLEidy0MwqgTHDt6CnSeXanNIwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.24.0", + "@typescript-eslint/utils": "8.24.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz", + "integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz", + "integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz", + "integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/typescript-estree": "8.24.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz", + "integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.20.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", + "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.11.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.20.0", + "@eslint/plugin-kit": "^0.2.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", + "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "build/bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.6.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.3.tgz", + "integrity": "sha512-NxbJyt1M5zffPcYZ8Nb53/8nnbIScmiLAMdoe0/FAszwb7lcSiX3iYBTsuF7RV84dZZJC8r3NghomrUXsmWvxQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.49.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.6", + "escape-string-regexp": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz", + "integrity": "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.6.0.tgz", + "integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==", + "license": "MIT", + "dependencies": { + "get-stream": "^9.0.1", + "strtok3": "^9.0.1", + "token-types": "^6.0.0", + "uint8array-extras": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "license": "ISC" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "license": "MIT" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz", + "integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz", + "integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==", + "dev": true, + "license": "Apache-2.0 AND MIT", + "dependencies": { + "es-module-lexer": "^1.5.3", + "slashes": "^3.0.12" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/peek-readable": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", + "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/slashes": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", + "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", + "dev": true, + "license": "ISC" + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.1.1.tgz", + "integrity": "sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.3.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", + "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tlds": { + "version": "1.259.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", + "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-types": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", + "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/twitter-api-v2": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/twitter-api-v2/-/twitter-api-v2-1.20.0.tgz", + "integrity": "sha512-YALKT3fOol6akmjpSTIjAkg3eXkDDXQ7k3J7naaaznMQwx+7PIEBvl03RPW9PwQDDQNRrAOfOxd3ghPQRjzkog==", + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typedoc": { + "version": "0.28.4", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.4.tgz", + "integrity": "sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.2.2", + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "yaml": "^2.7.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/uint8array-extras": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", + "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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==", + "license": "BSD-2-Clause" + }, + "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==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.74", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.74.tgz", + "integrity": "sha512-J8poo92VuhKjNknViHRAIuuN6li/EwFbAC8OedzI8uxpEPGiXHGQu9wemIAioIpqgfB4SySaJhdk0mH5Y4ICBg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index ea3fd44..3894721 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,70 @@ { - "name": "ayrshare-feed", - "version": "1.0.0", - "description": "Feeds data to ayrshare based on folders in feed dir; marks folder that are done.", + "name": "fairpost", + "version": "4.0.0", + "type": "module", + "engines": { + "node": "22.17.0" + }, + "description": "Publishes posts to social media platforms based on a single source feed", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "compile": "tsc" + "build": "npm run build:test && npm run build:exec && npm run build:post", + "build:test": "npx tsc", + "build:exec": "rm -rf build/* && ( npx tsc --noEmit false 1>/dev/null || true )", + "build:post": "npm run build:fix-imports", + "build:fix-imports": "sh -c 'if [ \"$(uname)\" = \"Darwin\" ]; then npm run build:fix-imports-mac; npm run build:fix-exports-mac; else npm run build:fix-imports-linux; npm run build:fix-exports-linux; fi'", + "build:fix-imports-mac": "find build -name '*.js' -exec sed -i '' 's/import \\(.*\\)\"\\(\\..*\\)\\.ts\"/import \\1\"\\2.js\"/g' {} +", + "build:fix-exports-mac": "find build -name '*.js' -exec sed -i '' 's/export \\(.*\\)\"\\(\\..*\\)\\.ts\"/export \\1\"\\2.js\"/g' {} +", + "build:fix-imports-linux": "find build -name '*.js' -exec sed -i 's/import \\(.*\\)\"\\(\\..*\\)\\.ts\"/import \\1\"\\2.js\"/g' {} +", + "build:fix-exports-linux": "find build -name '*.js' -exec sed -i 's/export \\(.*\\)\"\\(\\..*\\)\\.ts\"/export \\1\"\\2.js\"/g' {} +", + "htmldocs": "npx typedoc --entryPoints src/ --entryPointStrategy expand --out docs/html", + "test": "npx tsc && npm run lint", + "prettier": "prettier --config .prettierrc 'src/**/*.ts' --write", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "keywords": [ - "ayrshare", - "feed" + "feed", + "social", + "twitter", + "facebook", + "instagram", + "reddit", + "ayrshare" ], "author": "codepike", "license": "ISC", + "exports": { + ".": "./services/Fairpost.js", + "./types": "./types/index.js" + }, "dependencies": { + "@atproto/api": "^0.15.23", + "@flystorage/file-storage": "^1.0.1", + "@flystorage/local-fs": "^1.0.0", + "@googleapis/youtube": "^20.0.0", + "argon2": "^0.41.1", + "cookie": "^1.0.2", "dotenv": "^16.0.3", - "node-fetch": "^2.6.7", - "sharp": "^0.31.1" + "fast-xml-parser": "^4.3.2", + "google-auth-library": "^9.4.2", + "log4js": "^6.9.1", + "mime-types": "^2.1.35", + "node-fetch": "^3.3.2", + "sharp": "^0.33.5", + "twitter-api-v2": "^1.15.2" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-jsdoc": "^50.6.3", + "eslint-plugin-prettier": "^5.0.1", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "typedoc": "^0.28.4", + "typescript": "^5.0.4" } } diff --git a/public/auth/callback.html b/public/auth/callback.html new file mode 100644 index 0000000..81e057b --- /dev/null +++ b/public/auth/callback.html @@ -0,0 +1,46 @@ + + + + + + + fairpost-icon copy + + + + + + + + + + + +

Fairpost

+
+ The result returned by {{serviceName}} is +
{{result}}
+ This server will close now.
+ Return to your command line to see the next steps. + + \ No newline at end of file diff --git a/public/auth/request.html b/public/auth/request.html new file mode 100644 index 0000000..30a49c9 --- /dev/null +++ b/public/auth/request.html @@ -0,0 +1,48 @@ + + + + + + + fairpost-icon copy + + + + + + + + + + + +

Fairpost

+
+ Click here to tell {{serviceName}} to allow Fairpost to access your account:
+

+ {{serviceLink}} +

+ After allowing permissions there, {{serviceName}} should link you + back to this page with a code.
+ Fairpost will use that code + to request additional access tokens. + + \ No newline at end of file diff --git a/public/fairpost-icon.png b/public/fairpost-icon.png new file mode 100644 index 0000000..808e8a8 Binary files /dev/null and b/public/fairpost-icon.png differ diff --git a/public/fairpost-icon.svg b/public/fairpost-icon.svg new file mode 100644 index 0000000..e7ff72b --- /dev/null +++ b/public/fairpost-icon.svg @@ -0,0 +1,14 @@ + + + fairpost-icon copy + + + + + + + + + + + \ No newline at end of file diff --git a/public/privacy-policy.md b/public/privacy-policy.md new file mode 100644 index 0000000..84456ed --- /dev/null +++ b/public/privacy-policy.md @@ -0,0 +1,115 @@ +Privacy Policy +============== + +Last updated: December 09, 2023 + +This Privacy Policy describes The Softwares policies and procedures on the collection, use and disclosure of Your information when You use Fairpost and tells You about Your privacy rights and how the law protects You. + +The Software can use Your Personal data to provide the service. By using Fairpost, You agree to the collection and use of information in accordance with this Privacy Policy. + +By using Fairpost for any of the following platforms, you agree to the privacy policies defined for that platform: + +- YouTube: http://www.google.com/policies/privacy + +Interpretation and Definitions +------------------------------ + +### Interpretation + +The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural. + +### Definitions + +For the purposes of this Privacy Policy: + +* **Account** means a unique account created for You to access our Service or parts of our Service. + +* **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority. + +* **Application** refers to Fairpost, the software program provided by Fairpost (also referred to as either "The Software" or "Us" in this Agreement). + +* **Country** refers to: Netherlands + +* **Device** means any device that can access Fairpost such as a computer, a cellphone or a digital tablet. + +* **Personal Data** is any information that relates to an identified or identifiable individual. + +* **Service** refers to the Application's service. + +* **Service Provider** means any natural or legal person who processes the data on behalf of The Software. It refers to third-party companies or individuals employed by The Software to facilitate Fairpost, to provide Fairpost on behalf of The Software, to perform services related to Fairpost or to assist The Software in analyzing how Fairpost is used. + +* **Usage Data** refers to data collected automatically, either generated by the use of Fairpost or from Fairpost infrastructure itself (for example, the duration of a page visit). + +* **You** means the individual accessing or using Fairpost, or The Software, or other legal entity on behalf of which such individual is accessing or using Fairpost, as applicable. + + +Collecting and Using Your Personal Data +--------------------------------------- + +### Types of Data Collected + +#### Personal Data + +While using The Software, The Software may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to: + +### Use of Your Personal Data + +The Software may use Personal Data for the following purposes: + +* **To provide and maintain our Service**, including to monitor the usage of our Service. + +* **To manage Your Accounts:** to manage Your social feeds as a user of Fairpost. The Personal Data You provide can give You access to different functionalities of Fairpost that are available to You as a registered user on other platforms. + + +### Retention of Your Personal Data + +The Software will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. The Software will retain and use Your Personal Data to the extent necessary to comply with our legal obligations. + +The Software will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of The Software, or The Software are legally obligated to retain this data for longer time periods. + +### Transfer of Your Personal Data + +Your information, including Personal Data, is processed only where the software is executed. + +Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer. + +The Software will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information. + +### Delete Your Personal Data + +You have the right to delete the Personal Data that The Software have collected about You. + +The Software may give You the ability to delete certain information about You from within Fairpost. + +You may update, amend, or delete Your information at any time by using the software and changing the account settings section that allows you to manage Your personal information. + +### Disclosure of Your Personal Data + + +### Security of Your Personal Data + +The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While The Software strive to use commercially acceptable means to protect Your Personal Data, The Software cannot guarantee its absolute security. + + +Links to Other websites +----------------------- + +The Software may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. The Software strongly advise You to review the Privacy Policy of every site You visit. + +The Software has no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services. + +Changes to this Privacy Policy +------------------------------ + +Fairpost may update The Softwares Privacy Policy from time to time. The Software will notify You of any changes by posting the new Privacy Policy on this page. + +The Software will let You know via a prominent notice on The Software, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy. + +You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. + +Contact Us +---------- + +If you have any questions about this Privacy Policy, You can contact us: + +* By visiting this page on our website: [https://github.com/commonpike/fairpost/issues](https://github.com/commonpike/fairpost/issues) \ No newline at end of file diff --git a/public/term-of-use.md b/public/term-of-use.md new file mode 100644 index 0000000..89a8b12 --- /dev/null +++ b/public/term-of-use.md @@ -0,0 +1,175 @@ +# **Terms of Use** + +Our Terms of Use were last updated on 2024-01-23. + +Please read these terms and conditions carefully before using Our Software. + +## **Interpretation and Definitions** + +### **Interpretation** + +The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural. + +### **Definitions** + +For the purposes of these Terms of Use: + +- **"Affiliate"** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority. + +- **"Credentials"** means a unique identifiers entered by You for You to use our Software or parts of our Software. + +- **"Software"** (referred to as either "the Software", "We", "Us" or "Our" in this Agreement) refers to Fairpost. + +- **"Country"** refers to The Netherlands. + +- **"Content"** refers to content such as text, images, or other information that can be posted, uploaded, linked to or otherwise made available by You, regardless of the form of that content. + +- **"Device"** means any device that can access the Software such as a computer, a cellphone or a digital tablet. + +- **"Feedback"** means feedback, innovations or suggestions sent by You regarding the attributes, performance or features of our Software. + +- **"Terms of Use"** (also referred as "Terms" or "Terms of Use") mean these Terms of Use that form the entire agreement between You and the Software regarding the use of the Software. + +- **"Third-party Social Media Software"** means any services or content (including data, information, products or services) provided by a third-party that may be displayed, included or made available by the Software. + +- **"Website"** refers to the Fairpost repository, accessible from https://github.com/commonpike/fairpost + +- **"You"** means the individual accessing or using the Software, or the company, or other legal entity on behalf of which such individual is accessing or using the Software, as applicable. + +## **Acknowledgment** + +These are the Terms of Use governing the use of this Software and the agreement that operates between You and the Software. These Terms of Use set out the rights and obligations of all users regarding the use of the Software. + +Your access to and use of the Software is conditioned on Your acceptance of and compliance with these Terms of Use. These Terms of Use apply to all visitors, users and others who access or use the Software. + +By accessing or using the Software You agree to be bound by these Terms of Use. If You disagree with any part of these Terms of Use then You may not access the Software. + +You represent that you are over the age of 13\. The Software does not permit those under 13 to use the Software. + +Your access to and use of the Software is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Software. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Software. + +## **User Credentials** + +You are responsible for safeguarding the Credentials that You use to access the Software and for any activities or actions under Your Credentials, whether Your Credentials is with Our Software or a Third-Party Social Media Software. + +You agree not to disclose Your Credentials to any third party. You must notify Us immediately upon becoming aware of any breach of security or unauthorized use of Your Credentials. + +## **Relayed Terms of Use** + +By using the Software to publish content on Third-party Social Media Software, you agree to adhere to the Terms of Use and the Privacy Policy provided by that third party. Read the documentation on each platform, available on the Website, for more useful links and information. + +## **Content** + +### **Your Right to Post Content** + +Our Software allows You to post Content. You are responsible for the Content that You post using the Software, including its legality, reliability, and appropriateness. + +You retain any and all of Your rights to any Content You submit, post or display on or through the Software and You are responsible for protecting those rights. + +You represent and warrant that: (i) the Content is Yours (You own it) or You have the right to use it and grant Us the rights and license as provided in these Terms, and (ii) the posting of Your Content through the Software does not violate the privacy rights, publicity rights, copyrights, contract rights or any other rights of any person. + +### **Content Restrictions** + +The Software is not responsible for the content of the Software's users. You expressly understand and agree that You are solely responsible for the Content and for all activity that occurs under your accounts, whether done so by You or any third person using Your accounts. + +You may not transmit any Content that is unlawful, offensive, upsetting, intended to disgust, threatening, libelous, defamatory, obscene or otherwise objectionable. Examples of such objectionable Content include, but are not limited to, the following: + +- Unlawful or promoting unlawful activity. +- Defamatory, discriminatory, or mean-spirited content, including references or commentary about religion, race, sexual orientation, gender, national/ethnic origin, or other targeted groups. +- Spam, machine – or randomly – generated, constituting unauthorized or unsolicited advertising, chain letters, any other form of unauthorized solicitation, or any form of lottery or gambling. +- Containing or installing any viruses, worms, malware, trojan horses, or other content that is designed or intended to disrupt, damage, or limit the functioning of any software, hardware or telecommunications equipment or to damage or obtain unauthorized access to any data or other information of a third person. +- Infringing on any proprietary rights of any party, including patent, trademark, trade secret, copyright, right of publicity or other rights. +- Impersonating any person or entity including the Software and its employees or representatives. +- Violating the privacy of any third person. +- False information and features. + +As the Software cannot control all content posted by users and/or third parties on the Software, you agree to use the Software at your own risk. You agree that under no circumstances will the Software be liable in any way for any content, including any errors or omissions in any content, or any loss or damage of any kind incurred as a result of your use of any content. + +### **Content Backups** + +The Software does not guarantee there will be no loss or corruption of data. + +You agree to maintain a complete and accurate copy of any Content in a location independent of the Software. + +## **Copyright Policy** + +### **Intellectual Property Infringement** + + +If You are a copyright owner, or authorized on behalf of one, and You believe that the copyrighted work has been copied in a way that constitutes copyright infringement that is taking place through the Software, You must submit Your notice in writing to the Third-party Social Media Software to which the work was posted. + + +### **Intellectual Property** + +The Software is protected by copyright, trademark, and other laws of both the Country and foreign countries. + +Our trademarks and trade dress may not be used in connection with any product or service without the prior written consent of the Software. + +## **Links to Other Websites** + +Our Software conatins links to third-party web sites or services that are not owned or controlled by the Software. + +The Software has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Software shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods or services available on or through any such web sites or services. + +We strongly advise You to read the terms and conditions and privacy policies of all third-party web sites and services that You enable in the Software. + + +## **Limitation of Liability** + +Notwithstanding any damages that You might incur, the entire liability of the Software and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by You through the Software or 100 USD if You haven't purchased anything through the Software. + +To the maximum extent permitted by applicable law, in no event shall the Software or its suppliers be liable for any special, incidental, indirect, or consequential damages whatsoever (including, but not limited to, damages for loss of profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising out of or in any way related to the use of or inability to use the Software, third-party software and/or third-party hardware used with the Software, or otherwise in connection with any provision of this Terms), even if the Software or any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose. + +Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or consequential damages, which means that some of the above limitations may not apply. In these states, each party's liability will be limited to the greatest extent permitted by law. + +## **"AS IS" and "AS AVAILABLE" Disclaimer** + +The Software is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, the Software, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory or otherwise, with respect to the Software, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Software provides no warranty or undertaking, and makes no representation of any kind that the Software will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any performance or reliability standards or be error free or that any errors or defects can or will be corrected. + +Without limiting the foregoing, neither the Software nor any of the company's provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability of the Software, or the information, content, and materials or products included thereon; (ii) that the Software will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or currency of any information or content provided through the Software; or (iv) that the Software, its servers, the content, or e-mails sent from or on behalf of the Software are free of viruses, scripts, trojan horses, worms, malware, timebombs or other harmful components. + +Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law. + +## **Governing Law** + +The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Software. Your use of the Application may also be subject to other local, state, national, or international laws. + +## **Disputes Resolution** + +If You have any concern or dispute about the Software, You agree to first try to resolve the dispute informally by contacting the author. + +## **For European Union (EU) Users** + +If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which you are resident in. + +## **United States Legal Compliance** + +You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties. + +## **Severability and Waiver** + +### **Severability** + +If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and the remaining provisions will continue in full force and effect. + +### **Waiver** + +Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not effect a party's ability to exercise such right or require such performance at any time thereafter nor shall be the waiver of a breach constitute a waiver of any subsequent breach. + +## **Translation Interpretation** + +These Terms of Use may have been translated if We have made them available to You on our Software. +You agree that the original English text shall prevail in the case of a dispute. + +## **Changes to These Terms of Use** + +We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. + +By continuing to access or use Our Software after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please stop using the Software. + +## **Contact Us** + +If you have any questions about these Terms of Use, You can contact us: + +* By visiting this page on our website: https://github.com/commonpike/fairpost +* By posting a issue: https://github.com/commonpike/fairpost/issues diff --git a/server.js b/server.js new file mode 100755 index 0000000..e2c99cc --- /dev/null +++ b/server.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "build/server.js"; \ No newline at end of file diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..7ff27f7 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,2 @@ +import dotenv from "dotenv"; +dotenv.config(); diff --git a/src/classes/Feed.ts b/src/classes/Feed.ts deleted file mode 100644 index 8151a0a..0000000 --- a/src/classes/Feed.ts +++ /dev/null @@ -1,187 +0,0 @@ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as dotenv from 'dotenv'; -import Platform from "./Platform"; -import Folder from "./Folder"; -import Post from "./Post"; -import { PostStatus } from "./Post"; -import * as platforms from '../platforms'; -import { PlatformSlug } from '../platforms'; - -export default class Feed { - - path: string = ''; - platforms: { - [slug in PlatformSlug]? : Platform; - } = {}; - folders: Folder[] = []; - interval: number; - - constructor(configPath?: string) { - if (configPath) { - const configPathResolved = path.resolve(__dirname+'/../../../'+configPath); - dotenv.config({ path:configPathResolved }); - } else { - dotenv.config(); - } - if (process.env.FAYRSHARE_FEED_PATH) { - this.path = process.env.FAYRSHARE_FEED_PATH; - } else { - throw new Error('Problem reading .env config file'); - } - this.interval = Number(process.env.FAYRSHARE_FEED_INTERVAL ?? 7); - - const platformClasses = fs.readdirSync(path.resolve(__dirname+'/../platforms')); - platformClasses.forEach(file=> { - const constructor = file.replace('.ts','').replace('.js',''); - if (platforms[constructor] !== undefined) { - const platform = new platforms[constructor](); - if (platform.active) { - this.platforms[platform.slug] = platform; - } - } - }); - } - - getPlatforms(platforms?:PlatformSlug[]): Platform[] { - return platforms?.map(platform=>this.platforms[platform]) ?? Object.values(this.platforms); - } - - getAllFolders(): Folder[] { - if (this.folders.length) { - return this.folders; - } - if (!fs.existsSync(this.path)) { - fs.mkdirSync(this.path); - } - const paths = fs.readdirSync(this.path).filter(path => { - return fs.statSync(this.path+'/'+path).isDirectory() && - !path.startsWith('_') && !path.startsWith('.'); - }); - if (paths) { - this.folders = paths.map(path => new Folder(this.path+'/'+path)); - } - return this.folders; - } - - getFolders(paths?: string[]): Folder[] { - return paths?.map(path=>new Folder(this.path+'/'+path)) ?? this.getAllFolders(); - } - - getPosts(filters?: { - paths?:string[] - platforms?:PlatformSlug[], - status?:PostStatus - }): Post[] { - const posts: Post[] = []; - const platforms = this.getPlatforms(filters?.platforms); - const folders = this.getFolders(filters?.paths); - for (const folder of folders) { - for (const platform of platforms) { - const post = platform.getPost(folder); - if (post && (!filters?.status || filters.status.includes(post.status))) { - posts.push(post); - } - } - } - return posts; - } - - async preparePosts(filters?: { - paths?:string[] - platforms?:PlatformSlug[] - }): Promise { - - const posts: Post[] = []; - const platforms = this.getPlatforms(filters?.platforms); - const folders = this.getFolders(filters?.paths); - for (const folder of folders) { - for (const platform of platforms) { - const post = platform.getPost(folder); - if (post?.status!==PostStatus.PUBLISHED) { - const newPost = await platform.preparePost(folder); - if (newPost) { - posts.push(newPost); - } - } - } - } - return posts; - } - - - getLastPost(platform:PlatformSlug): Post | void { - let lastPost: Post = undefined; - const posts = this.getPosts({ - platforms: [platform], - status: PostStatus.PUBLISHED - }); - for (const post of posts) { - if (!lastPost || post.posted >= lastPost.posted) { - lastPost = post; - } - } - return lastPost; - } - - - getNextPostDate(platform:PlatformSlug): Date { - let nextDate = null; - const lastPost = this.getLastPost(platform); - if (lastPost) { - nextDate = new Date(lastPost.posted); - nextDate.setDate(nextDate.getDate()+this.interval); - } else { - nextDate = new Date(); - } - return nextDate; - } - - scheduleNextPosts(date?: Date, filters?: { - paths?:string[] - platforms?:PlatformSlug[] - }): Post[] { - const posts: Post[] = []; - const platforms = this.getPlatforms(filters?.platforms); - const folders = this.getFolders(filters?.paths); - for (const platform of platforms) { - const nextDate = date?date:this.getNextPostDate(platform.slug); - for (const folder of folders) { - const post = platform.getPost(folder); - if (post.valid && post?.status===PostStatus.UNSCHEDULED) { - post.schedule(nextDate); - posts.push(post); - break; - } - - } - } - return posts; - } - - async publishDuePosts(filters?: { - paths?:string[] - platforms?:PlatformSlug[] - }, dryrun:boolean = false): Promise { - const now = new Date(); - const posts: Post[] = []; - const platforms = this.getPlatforms(filters?.platforms); - const folders = this.getFolders(filters?.paths); - for (const folder of folders) { - for (const platform of platforms) { - const post = platform.getPost(folder); - if (post?.status===PostStatus.SCHEDULED) { - if (post.scheduled <= now) { - await platform.publishPost(post,dryrun); - posts.push(post); - break; - } - } - } - } - return posts; - } - - -} \ No newline at end of file diff --git a/src/classes/Folder.ts b/src/classes/Folder.ts deleted file mode 100644 index efd80d7..0000000 --- a/src/classes/Folder.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export default class Folder { - - path: string; - files?: { - text: string[], - image: string[], - video: string[], - other: string[] - }; - - constructor(path: string) { - this.path = path; - } - - getFiles() { - if (this.files!=undefined) { - return this.files; - } - const files = fs.readdirSync(this.path).filter(file => { - return fs.statSync(this.path+'/'+file).isFile() && - !file.startsWith('_') && - !file.startsWith('.'); - }); - this.files = { - text: [], - image: [], - video: [], - other: [] - }; - this.files.text = files.filter(file=>["txt"].includes(file.split('.')?.pop()??'')); - this.files.image = files.filter(file=>["jpg","jpeg","png"].includes(file.split('.')?.pop()??'')); - this.files.video = files.filter(file=>["mp4"].includes(file.split('.')?.pop()??'')); - this.files.other = files.filter(file=> - !this.files.text?.includes(file) - && !this.files.image?.includes(file) - && !this.files.video?.includes(file) - ); - return this.files; - } - -} \ No newline at end of file diff --git a/src/classes/Platform.ts b/src/classes/Platform.ts deleted file mode 100644 index 7ec7e08..0000000 --- a/src/classes/Platform.ts +++ /dev/null @@ -1,123 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import Folder from "./Folder"; -import Post from "./Post"; -import { PostStatus } from "./Post"; -import { PlatformSlug } from "../platforms"; - - - -export default class Platform { - - active: boolean = false; - slug: PlatformSlug = PlatformSlug.UNKNOWN; - defaultBody: string = "Fayrshare feed"; - - /* - * getPostFileName - * - * Return the intended name for a post of this - * platform to be saved in this folder. - */ - getPostFileName() { - return '_'+this.slug+'.json'; - } - - /* - * getPost - * - * Return the post for this platform for the - * given folder, if it exists. - */ - - getPost(folder: Folder): Post | undefined { - - console.log(this.slug,'getPost',folder.path); - - if (fs.existsSync(folder.path+'/'+this.getPostFileName())) { - const data = JSON.parse(fs.readFileSync(folder.path+'/'+this.getPostFileName(), 'utf8')); - if (data) { - return new Post(folder,this,data); - } - } - return; - } - - /* - * preparePost - * - * Prepare the post for this platform for the - * given folder, and save it. Optionally create - * derivates of media and save those, too. - * - * If the post exists and is published, ignore. - * If the post exists and is failed, set it back to - * unscheduled. - */ - async preparePost(folder: Folder): Promise { - - console.log(this.slug,'preparePost',folder.path); - - const post = this.getPost(folder) ?? new Post(folder,this); - if (post.status===PostStatus.PUBLISHED) { - return; - } - if (post.status===PostStatus.FAILED) { - post.status=PostStatus.UNSCHEDULED; - } - - - // some default logic. override this - // in your own platform if you need. - - post.files = folder.getFiles(); - - if (post.files.text?.includes('body.txt')) { - post.body = fs.readFileSync(post.folder.path+'/body.txt','utf8'); - } else { - post.body = this.defaultBody; - } - - if (post.files.text?.includes('title.txt')) { - post.title = fs.readFileSync(post.folder.path+'/title.txt','utf8'); - } else { - post.title = post.body.split('\n', 1)[0]; - } - - if (post.files.text?.includes('tags.txt')) { - post.tags = fs.readFileSync(post.folder.path+'/tags.txt','utf8'); - } - - if (post.title) { - post.valid = true; - } - - if (post.status===PostStatus.UNKNOWN) { - post.status=PostStatus.UNSCHEDULED; - } - - post.save(); - return post; - } - - /* - * publishPost - * - * publish the post for this platform, sync. - * set the posted date to now. - * add the result to post.results - * on success, set the status to published and return true, - * else set the status to failed and return false - */ - - async publishPost(post: Post, dryrun:boolean = false): Promise { - post.posted = new Date(); - post.results.push({ - error: 'publishing not implemented for '+this.slug - }); - post.status = PostStatus.FAILED; - return false; - } -} - - diff --git a/src/classes/Post.ts b/src/classes/Post.ts deleted file mode 100644 index 92f0876..0000000 --- a/src/classes/Post.ts +++ /dev/null @@ -1,68 +0,0 @@ - -import * as fs from 'fs'; -import * as path from 'path'; -import Folder from "./Folder"; -import Platform from "./Platform"; - -export default class Post { - folder: Folder; - platform: Platform; - valid: boolean = false; - status: PostStatus = PostStatus.UNKNOWN; - scheduled?: Date; - posted?: Date; - results: {}[] = []; - title: string = ''; - body?: string; - tags?: string; - files?: { - text: string[], - image: string[], - video: string[], - other: string[] - }; - - constructor(folder: Folder, platform: Platform, data?: any) { - this.folder = folder; - this.platform = platform; - if (data) { - Object.assign(this, data); - this.scheduled = data.scheduled ? new Date(data.scheduled): undefined; - this.posted = data.posted ? new Date(data.posted): undefined; - } - } - - - /* - * save - * - * Save this post for this platform for the - * given folder. - */ - - save(): void { - const data = { ...this}; - delete data.folder; - delete data.platform; - fs.writeFileSync( - this.folder.path+'/'+this.platform.getPostFileName(), - JSON.stringify(data,null,"\t") - ); - } - - schedule(date:Date): void { - this.scheduled = date; - this.status = PostStatus.SCHEDULED; - this.save(); - } - -} - -export enum PostStatus { - UNKNOWN = "unknown", - UNSCHEDULED = "unscheduled", - SCHEDULED = "scheduled", - PUBLISHED = "published", - FAILED = "failed" -} - diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..39d56ac --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,134 @@ +/* + 202402*pike + Fairpost cli handler +*/ + +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; + +import "./bootstrap.ts"; + +import Fairpost from "./services/Fairpost.ts"; +import { JSONReplacer, parsePayload } from "./utilities.ts"; +import { PlatformId } from "./platforms/index.ts"; +import { SourceStage, PostStatus } from "./types/index.ts"; +import Operator from "./models/Operator.ts"; +import User from "./models/User.ts"; + +// arguments +const USER = process.argv[2]?.includes("@") + ? process.argv[2].replace("@", "") + : ""; +const COMMAND = process.argv[2]?.includes("@") + ? (process.argv[3] ?? "help") + : (process.argv[2] ?? "help"); + +// options +const DRY_RUN = !!getOption("dry-run"); +const OPERATOR = (getOption("operator") as string) ?? "admin"; +const PASSWORD = (getOption("password") as string) ?? undefined; +const MODEL = (getOption("model") as string) ?? undefined; +const PLATFORMS = + ((getOption("platforms") as string)?.split(",") as PlatformId[]) ?? undefined; +const SOURCES = (getOption("sources") as string)?.split(",") ?? undefined; +const DATE = (getOption("date") as string) ?? undefined; +const STATUS = (getOption("status") as PostStatus) ?? undefined; +const STAGE = (getOption("stage") as SourceStage) ?? undefined; + +let PLATFORM = (getOption("platform") as string as PlatformId) ?? undefined; +let SOURCE = (getOption("source") as string) ?? undefined; +const POST = (getOption("post") as string) ?? undefined; +if (POST) { + [SOURCE, PLATFORM] = POST.split(":") as [string, PlatformId]; +} + +// payload +const chunks: Buffer[] = []; +if (!process.stdin.isTTY) { + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } +} +let PAYLOAD = chunks.length + ? await parsePayload(Buffer.concat(chunks)) + : undefined; + +// utilities +function getOption(key: string): boolean | string | null { + if (process.argv.includes(`--${key}`)) return true; + const value = process.argv.find((element) => element.startsWith(`--${key}=`)); + if (!value) return null; + return value.replace(`--${key}=`, ""); +} + +async function editPayload(getCommand: string, putCommand: string) { + const tmpFile = path.join(os.tmpdir(), "fairpost.tmp"); + fs.writeFileSync(tmpFile, await execute(getCommand), "utf8"); + const edit = spawnSync(`${process.env.EDITOR || "nano"} "${tmpFile}"`, { + stdio: "inherit", + shell: true, + }); + if (edit.error) { + console.error("Failed to launch editor:", edit.error); + process.exit(1); + } + if (edit.status !== 0) { + console.warn( + `Editor exited with code ${edit.status} — assuming user cancelled.`, + ); + process.exit(1); + } + PAYLOAD = await parsePayload(fs.readFileSync(tmpFile)); + return await execute(putCommand); +} + +// main +async function execute(command: string): Promise { + const operator = new Operator(OPERATOR, ["admin"], "cli", true); + const user = + USER && COMMAND !== "create-user" ? await User.getUser(USER) : undefined; + + try { + const output = await Fairpost.execute(operator, user, command, { + dryrun: DRY_RUN, + user: USER, + password: PASSWORD, + model: MODEL, + platforms: PLATFORMS, + platform: PLATFORM, + sources: SOURCES, + source: SOURCE, + date: DATE ? new Date(DATE) : undefined, + status: STATUS, + stage: STAGE, + payload: PAYLOAD, + }); + + return JSON.stringify(output, JSONReplacer, "\t"); + } catch (e) { + console.error((e as Error).message ?? e); + throw e; + } +} + +switch (COMMAND) { + case "edit-platform": + console.info(await editPayload("get-platform", "put-platform")); + break; + case "edit-user": + console.info(await editPayload("get-user", "put-user")); + break; + case "edit-feed": + console.info(await editPayload("get-feed", "put-feed")); + break; + case "edit-source": + console.info(await editPayload("get-source", "put-source")); + break; + case "edit-post": + console.info(await editPayload("get-post", "put-post")); + break; + default: + console.info(await execute(COMMAND)); +} diff --git a/src/config/log4js.json b/src/config/log4js.json new file mode 100644 index 0000000..e098c8b --- /dev/null +++ b/src/config/log4js.json @@ -0,0 +1,23 @@ +{ + "appenders": { + "global": { + "type": "dateFile", + "filename" : "var/log/fairpost.log", + "compress": true, + "numBackups" : 7 + }, + "user": { + "type": "multiFile", + "base": "var/log/", + "property": "userId", + "extension": ".log", + "maxLogSize": 10485760, + "backups": 3, + "compress": true + } + }, + "categories": { + "default": { "appenders": ["global"], "level": "info" }, + "user": { "appenders": ["user"], "level": "info" } + } +} \ No newline at end of file diff --git a/src/mappers/AbstractMapper.ts b/src/mappers/AbstractMapper.ts new file mode 100644 index 0000000..5b41590 --- /dev/null +++ b/src/mappers/AbstractMapper.ts @@ -0,0 +1,90 @@ +import User from "../models/User.ts"; +import Operator from "../models/Operator.ts"; +import { FieldMapping } from "../types/index.ts"; + +/** + * AbstractMapper - base for all mappers + * + * The mappers are used to create mapped versions of the models; + * for example, to send to or read from a client or a database. + * + * All mappers require a *user* because the user can optionally + * configure what data is visible, fe to the operator. + * + * For get operations, the mapper shall only return the fields + * allowed to be get by the operator. + * + * For set operations, the mapper shall return + * only the fields allowed to be set by the operator. + * + */ + +export default abstract class AbstractMapper { + protected user: User; + + constructor(user: User) { + this.user = user; + } + + protected abstract mapping: FieldMapping; + + public async getReport(operator: Operator): Promise { + const dto = await this.getDto(operator); + const lines: string[] = []; + for (const field in dto) { + let line = ""; + if (field in this.mapping) { + line += this.mapping[field].label + ": "; + } else { + line += field + ": "; + } + if (dto[field] instanceof Array) { + lines.push(line); + (dto[field] as string[]).forEach((item) => { + lines.push(" - " + String(item)); + }); + } else { + line += String(dto[field]); + lines.push(line); + } + } + return lines.join("\n"); + } + + /** + * Return a dto based on the operator + * @param operator + * @returns key/value pairs for the dto + */ + abstract getDto(operator: Operator): Promise; + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + abstract putDto(operator: Operator, dto: ModelDto): Promise; + + protected getDtoFields( + operator: Operator, + operation: "get" | "set", + ): string[] { + const permissions = operator.getPermissions(this.user); + const fields = Object.keys(this.mapping).filter((field) => { + if (this.mapping[field][operation].includes("none")) return false; + if (this.mapping[field][operation].includes("any")) return true; + if ( + this.mapping[field][operation].some( + (permission) => + permission in permissions && + permissions[permission as keyof typeof permissions], + ) + ) { + return true; + } + return false; + }); + return fields; + } +} diff --git a/src/mappers/FeedMapper.ts b/src/mappers/FeedMapper.ts new file mode 100644 index 0000000..2a4be5c --- /dev/null +++ b/src/mappers/FeedMapper.ts @@ -0,0 +1,90 @@ +import AbstractMapper from "./AbstractMapper.ts"; +import { FeedDto, FieldMapping } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; +import Feed from "../models/Feed.ts"; + +export default class FeedMapper extends AbstractMapper { + private feed: Feed; + static feedMapping: FieldMapping = { + model: { + type: "string", + label: "Model", + get: ["any"], + set: ["none"], + }, + id: { + type: "string", + label: "ID", + get: ["any"], + set: ["none"], + }, + user_id: { + type: "string", + label: "User ID", + get: ["any"], + set: ["none"], + }, + path: { + type: "string", + label: "Path", + get: ["manageFeed"], + set: ["none"], + }, + sources: { + type: "string[]", + label: "Feed sources", + get: ["manageFeed"], + set: ["none"], + required: false, + }, + }; + mapping = FeedMapper.feedMapping; + + constructor(feed: Feed) { + super(feed.user); + this.feed = feed; + } + + /** + * Return a dto based on the operator and operation + * @param operator + * @returns key/value pairs for the dto + */ + async getDto(operator: Operator): Promise { + const fields = this.getDtoFields(operator, "get"); + const dto: FeedDto = { + user_id: this.user.id, + model: "feed", + id: this.feed.id, + }; + for (const field of fields) { + switch (field) { + case "path": + dto[field] = this.feed.path; + break; + case "sources": + dto[field] = (await this.feed.getSources()).map((s) => s.id); + break; + } + } + return dto; + } + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + async putDto(operator: Operator, dto: FeedDto): Promise { + const fields = this.getDtoFields(operator, "set"); + for (const field in dto) { + if (fields.includes(field)) { + // there are no settable fields + } else { + this.user.log.trace("Ignoring field: " + field); + } + } + return true; + } +} diff --git a/src/mappers/PlatformMapper.ts b/src/mappers/PlatformMapper.ts new file mode 100644 index 0000000..58a9ec3 --- /dev/null +++ b/src/mappers/PlatformMapper.ts @@ -0,0 +1,160 @@ +import AbstractMapper from "./AbstractMapper.ts"; +import { PlatformDto, FieldMapping } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; +import Platform from "../models/Platform.ts"; + +export default class PlatformMapper extends AbstractMapper { + private platform: Platform; + private static platformMapping: FieldMapping = { + model: { + type: "string", + label: "Model", + get: ["any"], + set: ["none"], + }, + id: { + type: "string", + label: "ID", + get: ["managePlatforms"], + set: ["none"], + }, + user_id: { + type: "string", + label: "User ID", + get: ["managePlatforms"], + set: ["none"], + }, + active: { + type: "boolean", + label: "Active", + get: ["managePlatforms"], + set: ["managePlatforms"], + }, + // more fields from platform.settings + // added in mapper constructor + }; + + mapping = structuredClone(PlatformMapper.platformMapping); + + constructor(platform: Platform) { + super(platform.user); + this.platform = platform; + for (const key in platform.settings) { + this.mapping[key] = platform.settings[key]; + } + } + + /** + * Return a dto based on the operator and operation + * @param operator + * @returns key/value pairs for the dto + */ + async getDto(operator: Operator): Promise { + const fields = this.getDtoFields(operator, "get"); + const dto: PlatformDto = { + user_id: this.user.id, + model: "platform", + id: this.platform.id, + }; + for (const field of fields) { + switch (field) { + case "active": + dto[field] = !!this.platform.active; + break; + case "model": + case "id": + case "user_id": + break; + default: + switch (this.mapping[field].type) { + case "string": + dto[field] = String(this.user.data.get("settings", field, "")); + break; + case "string[]": + dto[field] = String( + this.user.data.get("settings", field, ""), + ).split(","); + break; + case "boolean": + dto[field] = this.user.data.get("settings", field, "") === "true"; + break; + case "integer": + dto[field] = parseInt(this.user.data.get("settings", field, "")); + break; + case "float": + dto[field] = parseFloat( + this.user.data.get("settings", field, ""), + ); + break; + case "json": + if (this.mapping[field].default) { + dto[field] = { + ...(this.mapping[field].default as object), + ...JSON.parse(this.user.data.get("settings", field, "{}")), + }; + } else { + dto[field] = JSON.parse( + this.user.data.get("settings", field, "{}"), + ); + } + break; + } + } + } + return dto; + } + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + async putDto(operator: Operator, dto: PlatformDto): Promise { + const fields = this.getDtoFields(operator, "set"); + for (const field in dto) { + if (fields.includes(field)) { + switch (field) { + case "active": + if (dto[field]) await this.user.addPlatform(this.platform.id); + else await this.user.removePlatform(this.platform.id); + break; + default: { + switch (this.mapping[field].type) { + case "string": + case "integer": + case "float": + this.user.data.set("settings", field, String(dto[field])); + break; + case "string[]": + this.user.data.set( + "settings", + field, + (dto[field] as string[]).join(","), + ); + break; + case "boolean": + this.user.data.set( + "settings", + field, + dto[field] ? "true" : "false", + ); + break; + case "json": + this.user.data.set( + "settings", + field, + JSON.stringify(dto[field]), + ); + break; + } + } + } + } else { + this.user.log.trace("Ignoring field: " + field); + } + } + await this.user.data.save(); + return true; + } +} diff --git a/src/mappers/PostMapper.ts b/src/mappers/PostMapper.ts new file mode 100644 index 0000000..ab4d0c6 --- /dev/null +++ b/src/mappers/PostMapper.ts @@ -0,0 +1,241 @@ +import AbstractMapper from "./AbstractMapper.ts"; +import { PostDto, FileInfo, FieldMapping } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; +import Post from "../models/Post.ts"; + +export default class PostMapper extends AbstractMapper { + private post: Post; + static postMapping: FieldMapping = { + model: { + type: "string", + label: "Model", + get: ["any"], + set: ["none"], + }, + id: { + type: "string", + label: "ID", + get: ["managePosts"], + set: ["none"], + }, + user_id: { + type: "string", + label: "User ID", + get: ["managePosts"], + set: ["none"], + }, + platform_id: { + type: "string", + label: "Platform ID", + get: ["managePosts"], + set: ["none"], + }, + source_id: { + type: "string", + label: "Source ID", + get: ["managePosts"], + set: ["none"], + }, + valid: { + type: "boolean", + label: "Valid", + get: ["managePosts"], + set: ["none"], + }, + status: { + type: "string", + label: "Status", + get: ["managePosts"], + set: ["none"], + }, + scheduled: { + type: "date", + label: "Scheduled date", + get: ["managePosts"], + set: ["managePosts"], + }, + published: { + type: "date", + label: "Published date", + get: ["managePosts"], + set: ["none"], + }, + title: { + type: "string", + label: "Title", + get: ["managePosts"], + set: ["managePosts"], + }, + body: { + type: "string", + label: "Body", + get: ["managePosts"], + set: ["managePosts"], + }, + tags: { + type: "string[]", + label: "Tags", + get: ["managePosts"], + set: ["managePosts"], + }, + mentions: { + type: "string[]", + label: "Mentions", + get: ["managePosts"], + set: ["managePosts"], + }, + geo: { + type: "string", + label: "Geo", + get: ["managePosts"], + set: ["managePosts"], + }, + files: { + type: "json", + label: "Files", + get: ["managePosts"], + set: ["managePosts"], + }, + ignore_files: { + type: "string[]", + label: "Ignore files", + get: ["managePosts"], + set: ["managePosts"], + }, + results: { + type: "json", + label: "Results", + get: ["managePosts"], + set: ["none"], + }, + remote_id: { + type: "string", + label: "Remote ID", + get: ["readPosts"], + set: ["none"], + }, + link: { + type: "string", + label: "Link", + get: ["readPosts"], + set: ["none"], + }, + }; + mapping = PostMapper.postMapping; + + constructor(post: Post) { + super(post.platform.user); + this.post = post; + } + + /** + * Return a dto based on the operator and operation + * @param operator + * @returns key/value pairs for the dto + */ + async getDto(operator: Operator): Promise { + const fields = this.getDtoFields(operator, "get"); + const dto: PostDto = { + user_id: this.user.id, + model: "post", + id: this.post.id, + }; + for (const field of fields) { + switch (field) { + case "platform_id": + dto[field] = this.post.platform.id; + break; + case "source_id": + dto[field] = this.post.source.id; + break; + case "valid": + dto[field] = this.post.valid; + break; + case "status": + dto[field] = this.post.status; + break; + case "scheduled": + dto[field] = this.post.scheduled?.toISOString(); + break; + case "published": + dto[field] = this.post.published?.toISOString(); + break; + case "title": + dto[field] = this.post.title; + break; + case "body": + dto[field] = this.post.body; + break; + case "tags": + dto[field] = this.post.tags; + break; + case "mentions": + dto[field] = this.post.mentions; + break; + case "geo": + dto[field] = this.post.geo; + break; + case "files": + dto[field] = this.post.files; + break; + case "ignore_files": + dto[field] = this.post.ignoreFiles; + break; + case "results": + dto[field] = this.post.results; + break; + case "remote_id": + dto[field] = this.post.remoteId; + break; + case "link": + dto[field] = this.post.link; + break; + } + } + //console.log(this.post,dto); + return dto; + } + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + async putDto(operator: Operator, dto: PostDto): Promise { + const fields = this.getDtoFields(operator, "set"); + for (const field in dto) { + if (fields.includes(field)) { + switch (field) { + case "scheduled": + this.post.scheduled = new Date((dto.scheduled as string) ?? ""); + break; + case "title": + this.post.title = (dto[field] as string) ?? ""; + break; + case "body": + this.post.body = (dto[field] as string) ?? ""; + break; + case "tags": + this.post.tags = (dto[field] as string[]) ?? []; + break; + case "mentions": + this.post.mentions = (dto[field] as string[]) ?? []; + break; + case "geo": + this.post.geo = (dto[field] as string) ?? ""; + break; + case "files": + this.post.files = (dto[field] as FileInfo[]) ?? []; + break; + case "ignore_files": + this.post.ignoreFiles = (dto[field] as string[]) ?? []; + break; + } + } else { + this.user.log.trace("Ignoring field: " + field); + } + } + return true; + } +} diff --git a/src/mappers/SourceMapper.ts b/src/mappers/SourceMapper.ts new file mode 100644 index 0000000..62a3604 --- /dev/null +++ b/src/mappers/SourceMapper.ts @@ -0,0 +1,121 @@ +import AbstractMapper from "./AbstractMapper.ts"; +import { SourceDto, FileInfo, FieldMapping } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; +import Source from "../models/Source.ts"; + +export default class SourceMapper extends AbstractMapper { + private source: Source; + static sourceMapping: FieldMapping = { + model: { + type: "string", + label: "Model", + get: ["any"], + set: ["none"], + }, + id: { + type: "string", + label: "ID", + get: ["manageSources"], + set: ["none"], + }, + user_id: { + type: "string", + label: "User ID", + get: ["manageSources"], + set: ["none"], + }, + feed_id: { + type: "string", + label: "Feed ID", + get: ["manageSources"], + set: ["none"], + }, + stage: { + type: "string", + label: "Stage", + get: ["manageSources"], + set: ["none"], + }, + path: { + type: "string", + label: "Path", + get: ["manageSources"], + set: ["none"], + }, + files: { + type: "json", + label: "Files", + get: ["manageSources"], + set: ["manageSources"], + }, + }; + mapping = SourceMapper.sourceMapping; + + constructor(source: Source) { + super(source.feed.user); + this.source = source; + } + + /** + * Return a dto based on the operator and operation + * @param operator + * @returns key/value pairs for the dto + */ + async getDto(operator: Operator): Promise { + const fields = this.getDtoFields(operator, "get"); + const dto: SourceDto = { + user_id: this.user.id, + model: "source", + id: this.source.id, + }; + for (const field of fields) { + switch (field) { + case "model": + dto[field] = "source"; + break; + case "id": + dto[field] = this.source.id; + break; + case "user_id": + dto[field] = this.user.id; + break; + case "feed_id": + dto[field] = this.source.feed.id; + break; + case "stage": + dto[field] = this.source.stage; + break; + case "path": + dto[field] = this.source.path; + break; + case "files": + dto[field] = await this.source.getFiles(); + break; + } + } + return dto; + } + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + async putDto(operator: Operator, dto: SourceDto): Promise { + const fields = this.getDtoFields(operator, "set"); + for (const field in dto) { + if (fields.includes(field)) { + switch (field) { + // upload here ? + case "files": + this.source.files = (dto[field] as FileInfo[]) ?? []; + break; + } + } else { + this.user.log.trace("Ignoring field: " + field); + } + } + return true; + } +} diff --git a/src/mappers/UserMapper.ts b/src/mappers/UserMapper.ts new file mode 100644 index 0000000..41cacff --- /dev/null +++ b/src/mappers/UserMapper.ts @@ -0,0 +1,96 @@ +import AbstractMapper from "./AbstractMapper.ts"; +import { UserDto, FieldMapping } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; + +export default class UserMapper extends AbstractMapper { + static userMapping: FieldMapping = { + model: { + type: "string", + label: "Model", + get: ["any"], + set: ["none"], + }, + id: { + type: "string", + label: "ID", + get: ["any"], + set: ["none"], // todo - there should be a rename-user command instead + }, + homedir: { + type: "string", + label: "Home directory", + get: ["manageUsers"], + set: ["none"], + required: false, + }, + loglevel: { + type: "string", + label: "Logger level", + get: ["manageUsers"], + set: ["manageUsers"], + required: false, + }, + report: { + type: "json", + label: "Report", + get: ["any"], + set: ["none"], + required: false, + }, + }; + mapping = UserMapper.userMapping; + + /** + * Return a dto based on the operator and operation + * @param operator + * @returns key/value pairs for the dto + */ + async getDto(operator: Operator): Promise { + const fields = this.getDtoFields(operator, "get"); + const dto: UserDto = { + model: "user", + id: this.user.id, + }; + for (const field of fields) { + switch (field) { + case "homedir": + dto[field] = this.user.homedir; + break; + case "loglevel": + dto[field] = this.user.data.get("settings", "LOGGER_LEVEL"); + break; + case "report": + dto[field] = await this.user.getReport(); + break; + } + } + return dto; + } + + /** + * Insert a given dto based on the operator + * @param operator + * @param dto + * @returns boolean success + */ + async putDto(operator: Operator, dto: UserDto): Promise { + const fields = this.getDtoFields(operator, "set"); + for (const field in dto) { + if (fields.includes(field)) { + switch (field) { + case "loglevel": + this.user.data.set( + "settings", + "LOGGER_LEVEL", + dto[field] as string, + ); + break; + } + } else { + this.user.log.trace("Ignoring field: " + field); + } + } + await this.user.data.save(); + return true; + } +} diff --git a/src/models/Feed.ts b/src/models/Feed.ts new file mode 100644 index 0000000..a85fdaf --- /dev/null +++ b/src/models/Feed.ts @@ -0,0 +1,169 @@ +import FeedMapper from "../mappers/FeedMapper.ts"; +import Source from "./Source.ts"; +import { SourceStage } from "../types/index.ts"; +import User from "./User.ts"; +import { basename } from "path"; + +/** + * Feed - the sources handler of fairpost + * + * The feed is a container of sources. The sources + * path is set by USER_FEEDPATH. Every dir in there, + * if not starting with _ or ., is a source. + * + * Every source can be prepared to become a post + * for a platform; but it's the platform that handles that. + */ +export default class Feed { + id: string = ""; + path: string = ""; + user: User; + cache: { [id: string]: Source } = {}; + allCached: { + [stage in SourceStage]?: boolean; + } = {}; + mapper: FeedMapper; + + constructor(user: User) { + this.user = user; + this.path = this.user.data.get("settings", "USER_FEEDPATH", ""); + this.id = this.user.id + ":feed"; + this.mapper = new FeedMapper(this); + } + + /** + * Get a report for this feed. This is + * part of the user report, which is updated + * as posts are processed and then cached + * @returns a report for this feed + */ + async getReport() { + // TODO check report cache first + const sources = { + [SourceStage.UNKNOWN]: 0, + [SourceStage.INCOMING]: 0, + [SourceStage.PENDING]: 0, + [SourceStage.ACTIVE]: 0, + [SourceStage.FINISHED]: 0, + [SourceStage.ARCHIVED]: 0, + }; + const currentSources = await this.getSources(); + const archivedSources = await this.getSources([], SourceStage.ARCHIVED); + for (const source of [...currentSources, ...archivedSources]) { + sources[source.stage] = sources[source.stage] + 1; + } + + return { + lastId: "todo", + nextId: "todo", + count: sources, + }; + } + + public clearCache() { + this.user.log.trace("Feed", "clearCache"); + this.cache = {}; + this.allCached = {}; + } + + /** + * getStagePath + * + * Get the path for a stage in a feed + * @param stage - the stage of the source + * @returns the path to the folder for the stage + */ + public getStagePath(stage: SourceStage): string { + const stageFolder = stage.toLowerCase(); // todo: map from .env + return this.path + "/" + stageFolder; + } + + /** + * Get multiple sources + * @param sourceIds optional array of ids of source you want to get + * @param stage optional stage of the sources you want to get + * @param includeArchived if no stages and no sourceIds are given, archived is excluded by default + * @returns all requested sources + */ + async getSources( + sourceIds?: string[], + stage?: SourceStage, + includeArchived = false, + ): Promise { + this.user.log.trace("Feed", "getSources", sourceIds ?? "", stage ?? ""); + if (!(await this.user.files.exists(this.path))) { + this.user.log.info("creating dir " + this.path); + await this.user.files.mkdir(this.path); + } + if (!sourceIds || !sourceIds.length) { + if (!stage) { + // requesting all sources + const stages = includeArchived + ? Object.values(SourceStage) + : Object.values(SourceStage).filter( + (v) => v !== SourceStage.ARCHIVED, + ); + await Promise.all(stages.map((stage) => this.getSources([], stage))); + // should all be in the cache now + this.user.log.trace( + "found " + Object.keys(this.cache).length + " sources", + ); + return Object.values(this.cache); + } else { + // requesting sources with a specific status + if (this.allCached[stage]) { + return Object.values(this.cache).filter( + (source) => source.stage === stage, + ); + } + const stagePath = this.getStagePath(stage); + if (!(await this.user.files.exists(stagePath))) { + return []; + } + const sources: Source[] = []; + const files = ( + await this.user.files.list(stagePath).toArray(true) + ).filter((entry) => { + if (entry.type === "file" || entry.isFile) return false; + const filename = basename(entry.path); + if (filename.startsWith("_")) return false; + if (filename.startsWith(".")) return false; + return true; + }); + + for await (const file of files) { + const source = await Source.getSource(this, basename(file.path)); + this.cache[source.id] = source; + sources.push(source); + } + this.allCached[stage] = true; + this.user.log.trace( + "found " + sources.length + " sources of stage " + stage, + ); + return sources; + } + } else { + // requesting sources with specific ids and optionally stage + const sources = await Promise.all( + sourceIds.map((sourceId) => this.getSource(sourceId, stage)), + ); + this.user.log.trace("found " + sources.length + " sources"); + return sources; + } + } + /** + * Get one source, and use a local cache. + * @param id - id of the source + * @param stage - optional stages to find the source in + * @returns the given source object + */ + async getSource(id: string, stage?: SourceStage): Promise { + this.user.log.trace("Feed", "getSource", id, stage); + if (id in this.cache) { + return this.cache[id]; + } + const source = await Source.getSource(this, id, stage); + this.cache[source.id] = source; + return source; + } +} diff --git a/src/models/Operator.ts b/src/models/Operator.ts new file mode 100644 index 0000000..83af827 --- /dev/null +++ b/src/models/Operator.ts @@ -0,0 +1,151 @@ +import User from "./User.ts"; +import { FieldMapping, ProcessedFieldMapping } from "../types/index.ts"; +import Platform from "../models/Platform.ts"; +import UserMapper from "../mappers/UserMapper.ts"; +import FeedMapper from "../mappers/FeedMapper.ts"; +import SourceMapper from "../mappers/SourceMapper.ts"; +import PostMapper from "../mappers/PostMapper.ts"; + +/** + * Operator - represents the user executing an operation or command. + * + * It is up to the interface to determine the operator's roles + * and check if they are properly authenticated. + * + */ + +export default class Operator { + private cache: { + [userid: string]: { + [permission: string]: boolean; + }; + } = {}; + + constructor( + public id: string = "anonymous", + private roles: ("admin" | "user" | "anonymous")[] = ["anonymous"], + public ui: "cli" | "api", + private authenticated: boolean, + ) {} + public validate() { + if (this.roles.includes("admin") && this.ui !== "cli") { + throw new Error("Trying to get permissions as admin from api"); + } + + if (!this.authenticated) { + if (this.roles.includes("admin") || this.roles.includes("user")) { + throw new Error("Trying to get permissions while unauthenticated"); + } + } + } + + public getPermissions(user?: User) { + const userid = user?.id; + if (userid && userid in this.cache) { + return this.cache[userid]; + } + const permissions = { + manageUsers: this.authenticated && this.roles.includes("admin"), + manageAccount: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + manageFeed: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + managePlatforms: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + manageSources: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + readPosts: !!user, + managePosts: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + publishPosts: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + schedulePosts: + !!user && + this.authenticated && + (this.id === user.id || this.roles.includes("admin")), + manageServer: + this.authenticated && this.ui === "cli" && this.roles.includes("admin"), + }; + if (userid) { + this.cache[userid] = permissions; + } + //user?.log.info(user.id,this.id,this.roles,this.authenticated); + //user?.log.info(permissions); + return permissions; + } + + public getFieldMapping( + user: User, + model: string, + instance?: object, + ): ProcessedFieldMapping { + let rawFieldMapping: FieldMapping | undefined = undefined; + let processedFieldMapping: ProcessedFieldMapping = {}; + switch (model) { + case "user": + rawFieldMapping = UserMapper.userMapping; + break; + + case "feed": + rawFieldMapping = FeedMapper.feedMapping; + break; + + case "source": + rawFieldMapping = SourceMapper.sourceMapping; + break; + + case "platform": + if (!instance || !(instance instanceof Platform)) { + throw user.log.error( + "Operator.getFieldMapping", + "Platform is required", + ); + } + const platform = instance as Platform; + rawFieldMapping = platform.mapper.mapping; + break; + + case "post": + rawFieldMapping = PostMapper.postMapping; + break; + } + if (!rawFieldMapping) { + throw user.log.error("Operator.getFieldMapping: no such mapping", model); + } + const permissions = this.getPermissions(user); + for (const fieldName of Object.keys(rawFieldMapping)) { + const rawField = rawFieldMapping[fieldName]; + const processedField = { ...rawField, get: true, set: true }; + for (const operation of ["get", "set"] as const) { + if (rawField[operation].includes("any")) + processedField[operation] = true; + else if (rawField[operation].includes("none")) + processedField[operation] = false; + else if ( + rawField[operation].some( + (permission) => + permission in permissions && + permissions[permission as keyof typeof permissions], + ) + ) + processedField[operation] = true; + } + if (processedField["get"]) { + processedFieldMapping[fieldName] = processedField; + } + } + return processedFieldMapping; + } +} diff --git a/src/models/Platform.ts b/src/models/Platform.ts new file mode 100644 index 0000000..51e4d61 --- /dev/null +++ b/src/models/Platform.ts @@ -0,0 +1,464 @@ +import * as pluginClasses from "../plugins/index.ts"; +import { PlatformId } from "../platforms/index.ts"; +import PlatformMapper from "../mappers/PlatformMapper.ts"; +import { FieldMapping, SourceStage, PostStatus } from "../types/index.ts"; + +import Source from "./Source.ts"; +import Operator from "./Operator.ts"; +import Plugin from "./Plugin.ts"; +import Post from "./Post.ts"; +import User from "./User.ts"; + +/** + * Platform base class to extend all platforms on + * + * When extending, implement at least + * preparePost() and publishPost() + */ +export default class Platform { + id: PlatformId = PlatformId.UNKNOWN; + active: boolean = false; + user: User; + cache: { [id: string]: Post } = {}; + defaultBody: string = "Fairpost feed"; + assetsFolder: string = "_fairpost"; + postFileName: string = "post.json"; + mapper!: PlatformMapper; // child *must* set this + settings: FieldMapping = {}; + interval: number; + constructor(user: User) { + this.user = user; + this.id = (this.constructor as typeof Platform).id(); + this.interval = Number( + this.user.data.get("settings", "FEED_INTERVAL", "7"), + ); + } + + /** + * Return the id of this platform as used in settings. + * By default, this is the lowercase name of the class, + * but you can override this in your own platform. + * @returns the id + */ + static id(): PlatformId { + return this.name.toLowerCase() as PlatformId; + } + + /** + * connect + * + * Set the platform up. Get the required keys and tokens. + * This may involve starting a webserver and/or communicating + * via the CLI. + * @param operator + * @param payload + * @returns - any object + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async connect(operator?: Operator, payload?: object): Promise { + throw this.user.log.error( + "No connect implemented for " + + this.id + + ". Read the docs in the docs folder.", + ); + } + + /** + * test + * + * Test the platform installation. This should not post + * anything, but test access tokens et al. It can return + * anything. + * @returns - any object + */ + async test(): Promise { + return "No tests implemented for " + this.id; + } + + /** + * refresh + * + * Refresh the platform installation. This usually refreshes + * access tokens if required. It can throw errors + * @returns - true if refreshed + */ + async refresh(): Promise { + this.user.log.trace("Platform", "Refresh not implemented for " + this.id); + return false; + } + + /** + * Get a report for this feed. This is + * part of the user report which is updated + * while the posts are being processed + * and then cached. + * @returns a report for this platform + */ + async getReport() { + this.user.log.trace("Platform", this.id, "getReport"); + // todo : check the cache first + const posts = { + [PostStatus.UNKNOWN]: 0, + [PostStatus.CANCELED]: 0, + [PostStatus.FAILED]: 0, + [PostStatus.UNSCHEDULED]: 0, + [PostStatus.SCHEDULED]: 0, + [PostStatus.PUBLISHED]: 0, + }; + const allPosts = await this.getPosts(); + for (const post of allPosts) { + posts[post.status] = posts[post.status] + 1; + } + return { + link: "todo", + count: posts, + lastId: "todo", + lastLink: "todo", + nextId: "todo", + }; + } + + /** + * getPostFilePath + * @param source the source for the new or existing post + * @returns the full path to the post file used + * to store data for a post of this platform + */ + getPostFilePath(source: Source): string { + return source.path + "/" + this.assetsFolder + "/" + this.postFileName; + } + + /** + * getPostId + * @param source the source for the new or existing post + * @returns the id for the new or existing post + */ + getPostId(source: Source): string { + return source.id + ":" + this.id; + } + /** + * getPost + * @param source - the source to get the post for this platform from + * @returns {Post} the post for this platform for the given source + */ + + async getPost(source: Source): Promise { + const postId = this.getPostId(source); + if (!(postId in this.cache)) { + this.user.log.trace("Platform", this.id, "getPost", source.id); + const post = await Post.getPost(this, source); + this.cache[postId] = post; + } + return this.cache[postId]; + } + + /** + * Get multiple (prepared) posts. by default, if no sources are + * given, it excludes posts from archived and incoming sources. + * @param sources - sources to filter on + * @param status - post status to filter on + * @param stage - if no sources are given, the stage to filter al sources on + * @returns multiple posts + */ + async getPosts( + sources?: Source[], + status?: PostStatus, + stage?: SourceStage, + ): Promise { + this.user.log.trace("Platform", this.id, "getPosts"); + const posts: Post[] = []; + if (!sources) { + sources = await this.user.getFeed().getSources([], stage); + const stages = stage + ? [stage] + : Object.values(SourceStage).filter( + (v) => v !== SourceStage.ARCHIVED && v !== SourceStage.INCOMING, + ); + const feed = this.user.getFeed(); + sources = ( + await Promise.all(stages.map((stage) => feed.getSources([], stage))) + ).flat(); + } + for (const source of sources) { + try { + const post = await this.getPost(source); + if (!status || status === post.status) { + posts.push(post); + } + } catch { + continue; + } + } + return posts; + } + + /** + * Get last published post for a platform (by default + * only from active and finished sources) + * @param includeAll - whether to include posts from archived and incoming sources + * @returns the above post or none + */ + async getLastPost(includeAll = false): Promise { + this.user.log.trace("Platform", this.id, "getLastPost"); + let lastPost: Post | undefined = undefined; + const stages = includeAll + ? Object.values(SourceStage) + : [SourceStage.ACTIVE, SourceStage.FINISHED]; + const feed = this.user.getFeed(); + const sources = ( + await Promise.all(stages.map((stage) => feed.getSources([], stage))) + ).flat(); + const posts = await this.getPosts(sources, PostStatus.PUBLISHED); + for (const post of posts) { + if (post.published) { + if ( + !lastPost || + !lastPost.published || + post.published >= lastPost.published + ) { + lastPost = post; + } + } + } + return lastPost; + } + + /** + * Get first post from sources scheduled in the past + * This also does some janitor checks .. + * - if a post is scheduled without a date, it will be unscheduled + * - if a post is already published, it will be marked as such + * @param sources + * @returns the above post or none + */ + async getDuePost(sources: Source[]): Promise { + const now = new Date(); + for (const source of sources) { + const post = await this.getPost(source); + if ( + post && + (post.status === PostStatus.SCHEDULED || + post.status === PostStatus.FAILED) + ) { + // TODO: some janitor checks + if (!post.scheduled) { + this.user.log.warn( + "Found scheduled post without date. Unscheduling post.", + post.id, + ); + post.status = PostStatus.UNSCHEDULED; + await post.save(); + continue; + } + if (post.published) { + this.user.log.warn( + "Found scheduled post previously published. Marking published.", + post.id, + ); + post.status = PostStatus.PUBLISHED; + await post.save(); + continue; + } + if (post.scheduled <= now) { + this.user.log.trace( + "Platform", + this.id, + "getDuePost", + post.id, + "Posting; scheduled for", + post.scheduled, + ); + return post; + break; + } else { + this.user.log.trace( + "Platform", + this.id, + post.id, + "Not due yet; scheduled for", + post.scheduled, + ); + } + } + } + } + /** + * preparePost + * + * Prepare a post for this platform for the + * given source. If it doesn't exist, create it. + * + * Override this in your own platform, but + * always call super.preparePost() + * + * If the post exists and is published, ignores it. + * If the post exists and is failed, sets it back to + * unscheduled. + * + * Do not throw errors. Instead, catch and log them, + * and set the post.valid to false + * + * Presume the post may have already been prepared + * before, and manually adapted later. For example, + * post.status may have manually been set to canceled. + * @param source - the source for which to prepare a post for this platform + * @param save - wether to save the post already + * @returns the prepared post + */ + async preparePost(source: Source, save?: true): Promise { + this.user.log.trace("Platform", this.id, "preparePost"); + const post = await this.getPost(source); + if (post.status === PostStatus.PUBLISHED) { + return post; + } + await post.prepare(); + if (post.status === PostStatus.UNKNOWN) { + post.status = PostStatus.UNSCHEDULED; + } + if (post.status === PostStatus.FAILED) { + post.status = PostStatus.UNSCHEDULED; + } + if (save) { + await post.save(); + } + + return post; + } + + /** + * Get the next date for a post to be published on this platform + * + * This would be FAIRPOST_INTERVAL days after the date + * of the last post for that platform, or now. + * @param includeAll - whether to check for published posts from incoming, pending and archived sources + * @returns the next date + */ + async getNextPostDate(includeAll = false): Promise { + this.user.log.trace("Feed", "getNextPostDate"); + let nextDate = null; + const lastPost = await this.getLastPost(includeAll); + if (lastPost && lastPost.published) { + nextDate = new Date(lastPost.published); + nextDate.setDate(nextDate.getDate() + this.interval); + } else { + nextDate = new Date(); + } + return nextDate; + } + + /** + * Schedule the first unscheduled post for this platforms + * + * If no sources are given, searches for sources in + * pending and active stages, or all if includeAll is given. + * + * If no date is given, finds the last post date within pending, + * active and finished sources, or all if includeAll is given. + * and calculates the next date based on that. + * + * within given sources, if there is a scheduled post, returns that one. + * else, finds the first unscheduled post, and schedules that post on + * the next date. + * @param date - use date instead of the next post date + * @param sources - paths to sources to filter on + * @param includeAll - whether to consider incoming, finished and archived sources for last post date and unscheduled posts + * @returns the next scheduled post or undefined if there are no posts to schedule + */ + async scheduleNextPost( + date?: Date, + sources?: Source[], + includeAll: boolean = false, + ): Promise { + this.user.log.trace("Platform", this.id, "scheduleNextPost"); + if (!sources) { + // by default, only check pending and active sources + const stages = includeAll + ? Object.values(SourceStage) + : [SourceStage.PENDING, SourceStage.ACTIVE]; + const feed = this.user.getFeed(); + sources = ( + await Promise.all(stages.map((stage) => feed.getSources([], stage))) + ).flat(); + } + const scheduledPosts = await this.getPosts(sources, PostStatus.SCHEDULED); + if (scheduledPosts.length) { + this.user.log.trace( + "Platform", + this.id, + "scheduleNextPost", + "Already scheduled", + ); + return scheduledPosts[0]; + } + // by default, only check pending, active and finished sources + const nextDate = date ? date : await this.getNextPostDate(includeAll); + for (const source of sources) { + const post = await this.getPost(source); + if (post && post.valid && post.status === PostStatus.UNSCHEDULED) { + await post.schedule(nextDate); + return post; + } + } + this.user.log.trace( + this.id, + "scheduleNextPost", + "No post left to schedule", + ); + } + + /** + * publishPost + * + * - your platform should implement this itself. + * - publish the post for this platform, sync. + * - when done, pass the result to post.processResult() + * + * do not throw errors, instead catch and log them, and + * set the post to failed. + * @returns {Promise} succes status + */ + + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Platform", this.id, "publishPost", post.id, dryrun); + return await post.processResult("-99", "#undefined", { + date: new Date(), + dryrun: dryrun, + success: false, + response: {}, + error: new Error("publishing not implemented for " + this.id), + }); + } + + /** + * publishDuePost + * + * - publish the first post scheduled in the past for this platform. + * @returns {Promise} the post or none + */ + + async publishDuePost( + sources: Source[], + dryrun: boolean = false, + ): Promise { + this.user.log.trace("Platform", this.id, "publishDuePost", dryrun); + const post = await this.getDuePost(sources); + if (post) { + await post.publish(dryrun); + return post; + } + } + + /** + * @returns array of instances of the plugins given with the settings given. + */ + loadPlugins(pluginSettings: { [pluginid: string]: object }): Plugin[] { + const plugins: Plugin[] = []; + Object.values(pluginClasses).forEach((pluginClass) => { + const pluginId = pluginClass.id(); + if (pluginId in pluginSettings) { + plugins?.push(new pluginClass(pluginSettings[pluginId])); + } + }); + return plugins; + } +} diff --git a/src/models/Plugin.ts b/src/models/Plugin.ts new file mode 100644 index 0000000..aeefae2 --- /dev/null +++ b/src/models/Plugin.ts @@ -0,0 +1,40 @@ +import Post from "./Post.ts"; + +/** + * Plugin - base class to extend plugins from + * + * A plugins processes a post during Platform.preparePost, + * based on the given settings, eg to make + * black and white images on Facebook but colored ones + * on Instagram. + * + */ +export default class Plugin { + static defaults: object = {}; + id: string; + + constructor() { + this.id = (this.constructor as typeof Plugin).id(); + } + + /** + * Return the id of this plugin as used in settings. + * By default, this is the lowercase name of the class, + * but you can override this in your own platform. + * @returns the id + */ + static id(): string { + return this.name.toLowerCase() as string; + } + + /** + * Process the post + */ + + async process(post: Post): Promise { + throw post.platform.user.log.error( + this.id, + "process() not implemented. Read the docs in the docs folder.", + ); + } +} diff --git a/src/models/Post.ts b/src/models/Post.ts new file mode 100644 index 0000000..c0dc816 --- /dev/null +++ b/src/models/Post.ts @@ -0,0 +1,718 @@ +import { FileGroup, FileInfo, PostStatus, PostResult } from "../types/index.ts"; +import Source from "./Source.ts"; +import Platform from "./Platform.ts"; +import { isSimilarArray } from "../utilities.ts"; +import PostMapper from "../mappers/PostMapper.ts"; + +/** + * Post - a post within a source + * + * A post belongs to one platform and one source; + * it is *prepared* and later *published* by the platform. + * The post serializes to a json file in the source, + * where it can be read later for further processing. + * + * The post does not actually handle files; it handles + * its index which is finally written to disk. It does + * read the file contents and check if the files + * actually exist. If you want to add files to a post, + * copy them in place yourself (in your platform class) + * and add it to the post using methods below. + */ +export default class Post { + id: string; + source: Source; + platform: Platform; + valid: boolean = false; + status: PostStatus = PostStatus.UNKNOWN; + prepared: boolean = false; + private originalStatus: PostStatus = PostStatus.UNKNOWN; + scheduled?: Date; + published?: Date; + results: PostResult[] = []; + title: string = ""; + body?: string; + tags?: string[]; + mentions?: string[]; + geo?: string; + files?: FileInfo[]; + ignoreFiles?: string[]; + link?: string; + remoteId?: string; + mapper: PostMapper; + + /** + * Dont call the constructor yourself; + * instead, call `await Post.getPost()` + * @param platform + * @param source + */ + constructor(platform: Platform, source: Source) { + this.id = platform.getPostId(source); + this.platform = platform; + this.source = source; + this.mapper = new PostMapper(this); + } + + /** + * getPost + * + * get a new post and load the async data. + * @param platform - the platform this post belongs to + * @param source - the source this post is derived from + * @returns new post object + */ + static async getPost(platform: Platform, source: Source): Promise { + const post = new Post(platform, source); + const postFilePath = platform.getPostFilePath(source); + if (!(await platform.user.files.exists(postFilePath))) { + return post; + } + const contents = await platform.user.files.readFile(postFilePath); + const data = JSON.parse(contents); + if (!data) { + throw platform.user.log.error( + "Cant parse post ", + post.id, + post.source.id, + ); + } + Object.assign(post, data); + post.id = platform.getPostId(source); + post.prepared = true; + post.scheduled = post.scheduled ? new Date(post.scheduled) : undefined; + post.published = post.published ? new Date(post.published) : undefined; + post.ignoreFiles = post.ignoreFiles ?? []; + post.originalStatus = post.status; + + return post; + } + + /** + * Save this post to disk + */ + + async save() { + this.platform.user.log.trace("Post", "save"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = { ...this } as { [key: string]: any }; + delete data.source; + delete data.platform; + delete data.mapper; + delete data.prepared; + delete data.originalStatus; + await this.platform.user.files.write( + this.platform.getPostFilePath(this.source), + JSON.stringify(data, null, "\t"), + ); + + if (this.originalStatus !== this.status) { + // update the source status if necessary + // note, this may *move* the source and all posts + const originalSourceStage = this.source.stage; + const newSourceStage = await this.source.updateStage(); + + // update the users report + const report = await this.platform.user.getReport(); + if (report.platforms[this.platform.id]) { + if (!report.platforms[this.platform.id]?.count[this.originalStatus]) { + report.platforms[this.platform.id]!.count[this.originalStatus] = 1; + } + if (!report.platforms[this.platform.id]?.count[this.status]) { + report.platforms[this.platform.id]!.count[this.status] = 0; + } + report.platforms[this.platform.id]!.count[this.originalStatus]!--; + report.platforms[this.platform.id]!.count[this.status]!++; + + if (originalSourceStage !== newSourceStage) { + if (!report.feed.count[originalSourceStage]) { + report.feed.count[originalSourceStage] = 1; + } + if (!report.feed.count[newSourceStage]) { + report.feed.count[newSourceStage] = 0; + } + report.feed.count[originalSourceStage]!--; + report.feed.count[newSourceStage]!++; + } + // save the report + await this.platform.user.putReport(report); + this.platform.user.log.trace( + "Post", + this.id, + "save", + "updated user report", + ); + } + // all up to date + this.originalStatus = this.status; + } + } + + /** + * Prepare this post + * + * Called from Platform.preparePost; + * + * The post may already be prepared before, + * but then things may have changed. + * + * always updates the files, they may have changed + * on disk; but also maintains some properties that may have + * been changed manually + * + * Does not save the post. + */ + + async prepare() { + this.platform.user.log.trace("Post", "prepare"); + + // purge non-existing files and + // update existing files + + if (!this.prepared) { + const assetsPath = this.getFilePath(this.platform.assetsFolder); + if (!(await this.platform.user.files.exists(assetsPath))) { + await this.platform.user.files.mkdir(assetsPath); + } + } else { + await this.purgeFiles(); + } + + // get all files and process them + + const files = await this.source.getFiles(); + files.forEach((file) => { + if (!this.ignoreFiles?.includes(file.name)) { + this.putFile(file); + } + }); + this.reorderFiles(); + + // read textfiles and stick their contents + // into appropriate properties - body, title, etc + + const textFiles = this.getFiles(FileGroup.TEXT); + + if (this.hasFile("body.txt")) { + this.body = await this.platform.user.files.readFile( + this.getFilePath("body.txt"), + ); + } else if (textFiles.length === 1) { + const bodyFile = textFiles[0].name; + this.body = await this.platform.user.files.readFile( + this.getFilePath(bodyFile), + ); + } else { + this.body = this.platform.defaultBody; + } + + if (this.hasFile("title.txt")) { + this.title = await this.platform.user.files.readFile( + this.getFilePath("title.txt"), + ); + } else if (this.hasFile("subject.txt")) { + this.title = await this.platform.user.files.readFile( + this.getFilePath("subject.txt"), + ); + } + + if (this.hasFile("tags.txt")) { + this.tags = ( + await this.platform.user.files.readFile(this.getFilePath("tags.txt")) + ).split(/\s/); + } + if (this.hasFile("mentions.txt")) { + this.mentions = this.mentions = ( + await this.platform.user.files.readFile( + this.getFilePath("mentions.txt"), + ) + ).split(/\s/); + } + if (this.hasFile("geo.txt")) { + this.geo = await this.platform.user.files.readFile( + this.getFilePath("geo.txt"), + ); + } + + // decompile the body to see if there are + // appropriate metadata in there - title, tags, .. + + this.decompileBody(); + + // validate and set status + + if (this.title) { + this.valid = true; + } + + // done + } + + /** + * Change this posts status and save it + * + * this just sets the status to whatever given; also updates + * or removes published and scheduled dates to match + * @param status - the status to change it to + */ + + async setStatus(status: PostStatus) { + this.platform.user.log.trace("Post", "setStatus", status); + if (!this.prepared) { + throw this.platform.user.log.error("Post is not prepared"); + } + if (!this.valid) { + throw this.platform.user.log.error("Post is not valid"); + } + + if (this.status === status) { + throw this.platform.user.log.error("Post already on status " + status); + } + this.platform.user.log.warn("Changing post status to " + status); + switch (status) { + case PostStatus.UNSCHEDULED: + this.platform.user.log.warn("Removing scheduled and published dates"); + delete this.scheduled; + delete this.published; + break; + case PostStatus.SCHEDULED: + this.platform.user.log.warn( + "Resetting scheduled date, removing published date, r", + ); + this.scheduled = this.scheduled || new Date(); + delete this.published; + break; + case PostStatus.PUBLISHED: + this.platform.user.log.warn("Resetting scheduled and published dates"); + this.scheduled = this.scheduled || new Date(); + this.published = this.published || new Date(); + break; + } + this.status = status; + await this.save(); + } + + /** + * Schedule this post and save it + * + * this just sets the 'scheduled' date + * @param date - the date to schedule it on + */ + + async schedule(date: Date) { + this.platform.user.log.trace("Post", "schedule", date); + if (!this.prepared) { + throw this.platform.user.log.error("Post is not prepared"); + } + if (!this.valid) { + throw this.platform.user.log.error("Post is not valid"); + } + if (this.status === PostStatus.CANCELED) { + throw this.platform.user.log.error("Post has status canceled"); + } + if (this.status !== PostStatus.UNSCHEDULED) { + this.platform.user.log.warn("Rescheduling post"); + } + this.scheduled = date; + this.status = PostStatus.SCHEDULED; + await this.save(); + } + + /** + * Publish this post and return it + * + * The post itself is a fixed entity and does not + * know how to publish itself, so it calls on its + * parent, in userland, to perform the logic. + * @param dryrun - wether or not to really really publish it + * @returns boolean if success + */ + async publish(dryrun: boolean): Promise { + this.platform.user.log.trace("Post", "publish"); + if (!this.prepared) { + throw this.platform.user.log.error("Post is not prepared"); + } + if (!this.valid) { + throw this.platform.user.log.error("Post is not valid", this.id); + } + if (this.status === PostStatus.CANCELED) { + throw this.platform.user.log.error("Post has status canceled", this.id); + } + if (this.published) { + throw this.platform.user.log.error("Post was already published", this.id); + } + // why ? + // if (!dryrun) post.schedule(now); + this.platform.user.log.info("Publishing", this.id); + return await this.platform.publishPost(this, dryrun); + } + + /** + * Check body for title, #tags, \@mentions and %geo + * and store those in separate fields instead. + * Does not save. + */ + decompileBody() { + const lines = this.body?.trim().split("\n") ?? []; + + // chop title + const title = lines[0]; + if (!this.title || this.title === title) { + this.title = title ?? ""; + lines.shift(); + this.body = lines.join("\n"); + } + + // chop body tail for #tags, @mentions + // and %geo - any geo + + const rxtag = /#\S+/g; + const rxtags = /^\s*((#\S+)\s*)+$/g; + const rxmention = /@\S+/g; + const rxmentions = /^\s*((@\S+)\s*)+$/g; + const rxgeo = /^%geo\s+(.*)/i; + let line = ""; + while (lines.length) { + line = lines.pop() ?? ""; + + if (!line.trim()) { + this.body = lines.join("\n"); + continue; + } + + if (line.match(rxtags)) { + const tags = line.match(rxtag); + if (tags && (!this.tags?.length || isSimilarArray(tags, this.tags))) { + this.tags = tags; + this.body = lines.join("\n"); + } + continue; + } + + if (line.match(rxmentions)) { + const mentions = line.match(rxmention); + if ( + mentions && + (!this.mentions?.length || isSimilarArray(mentions, this.mentions)) + ) { + this.mentions = mentions; + this.body = lines.join("\n"); + } + continue; + } + + if (line.match(rxgeo)) { + const geo = line.match(rxgeo)?.[1] ?? ""; + if (!this.geo || this.geo === geo) { + this.geo = geo; + this.body = lines.join("\n"); + } + continue; + } + + break; + } + } + + /** + * Create a body containing the given arguments. + * @param parts - any of 'title','body','tags','mentions','geo' + * prepending a ! to every part removes those parts from the default array instead. + * @returns compiled body + */ + getCompiledBody(...parts: string[]): string { + const defaultParts = ["title", "body", "tags", "mentions", "geo"]; + if (!parts.length) { + parts = defaultParts; + } + if (parts.every((part) => part.startsWith("!"))) { + let realParts = defaultParts; + parts.forEach((remove) => { + realParts = realParts.filter((part) => part != remove.substring(1)); + }); + parts = realParts; + } + + let body = ""; + for (const part of parts) { + switch (part) { + case "title": + body += this.title ? this.title + "\n" : ""; + break; + case "body": + body += this.body ? this.body + "\n\n" : ""; + break; + case "tags": + body += this.tags ? this.tags.join(" ") + "\n" : ""; + break; + case "mentions": + body += this.mentions ? this.mentions.join(" ") + "\n" : ""; + break; + case "geo": + body += this.geo ? this.geo + "\n" : ""; + break; + } + } + return body.trim(); + } + + /** + * @returns the files grouped by their group property + */ + getGroupedFiles(): { [group in FileGroup]?: FileInfo[] } { + return ( + this.files?.reduce(function ( + collector: { [group in FileGroup]?: FileInfo[] }, + file: FileInfo, + ) { + (collector[file["group"]] = collector[file["group"]] || []).push(file); + return collector; + }, {}) ?? {} + ); + } + + /** + * @param groups - names of groups to return files from + * @returns the files within those groups, sorted by order + */ + getFiles(...groups: FileGroup[]): FileInfo[] { + if (!groups.length) { + return this.files?.sort((a, b) => a.order - b.order) ?? []; + } + return ( + this.files + ?.filter((file) => groups.includes(file.group)) + .sort((a, b) => a.order - b.order) ?? [] + ); + } + + /** + * @param groups - names of groups to require files from + * @returns boolean if files in post + */ + hasFiles(...groups: FileGroup[]): boolean { + if (!groups.length) { + return !!(this.files?.length ?? 0); + } + return !!( + this.files?.filter((file) => groups.includes(file.group)).length ?? 0 + ); + } + + /** + * @param group - the name of the group for which to remove the files + * Does not save. + */ + removeFiles(group: FileGroup) { + this.files = this.files?.filter((file) => file.group !== group); + } + + /** + * @param group - the name of the group for which to remove some files + * @param size - the number of files to leave in the group + */ + limitFiles(group: FileGroup, size: number) { + this.getFiles(group).forEach((file, index) => { + if (index >= size) { + this.removeFile(file.name); + } + }); + } + + /** + * Remove all the files that do not exist (anymore). + * Does not save. + */ + async purgeFiles() { + for (const file of this.getFiles()) { + if ( + file.original && + !(await this.platform.user.files.exists(file.original)) + ) { + this.platform.user.log.info( + "Post", + "purgeFiles", + "purging non-existant derivate", + file.name, + ); + this.removeFile(file.name); + } + if ( + !(await this.platform.user.files.exists(this.getFilePath(file.name))) + ) { + this.platform.user.log.info( + "Post", + "purgeFiles", + "purging non-existent file", + file.name, + ); + this.removeFile(file.name); + } + } + } + + /** + * reindex file ordering to remove doubles. + * Does not save. + */ + reorderFiles() { + this.files + ?.sort((a, b) => a.order - b.order) + .forEach((file, index) => { + file.order = index; + }); + } + + /** + * @param name the name of the file + * @returns wether the file exists + */ + hasFile(name: string): boolean { + return this.getFile(name) !== undefined; + } + + /** + * @param name - the name of the file + * @returns the files info if any + */ + getFile(name: string): FileInfo | undefined { + return this.files?.find((file) => file.name === name); + } + + /** + * Add the file info of file `name` to the files of + * this post. Returns undefined if it already exists. + * + * Does not save. + * @param name - the name of the file to add + * @returns the info of the added file + */ + async addFile(name: string): Promise { + const index = this.files?.findIndex((file) => file.name === name) ?? -1; + if (index === -1) { + const newFile = await this.source.getFileInfo( + name, + this.files?.length ?? 0, + ); + if (!this.files) { + this.files = []; + } + this.platform.user.log.trace("Post.addFile", newFile); + this.files.push(newFile); + return newFile; + } else { + this.platform.user.log.warn( + "Post.addFile", + "Not replacing existing file", + name, + ); + } + } + + /** + * @param file - the fileinfo to add or replace. + * Does not save. + */ + putFile(file: FileInfo) { + const oldFile = this.files?.find( + (oldfile) => oldfile.name === file.name || oldfile.original === file.name, + ); + if (oldFile) { + file.order = oldFile.order; + this.removeFile(oldFile.name); + } + if (!this.files) this.files = []; + this.files.push(file); + } + + /** + * @param name the name of the file to remove. + * Does not save. + */ + removeFile(name: string) { + this.files = this.files?.filter((file) => file.name !== name); + } + + /** + * Replace the file info of file `search` for new info + * gathered for file `replace`. Keeps the oldfile order + * and sets replace.original to search.name + * + * Does not save. + * @param search - the name of the file to replace + * @param replace - the name of the file to replace it with + * @returns the info of the replaced file + */ + async replaceFile( + search: string, + replace: string, + ): Promise { + this.platform.user.log.trace("Post.replaceFile", search, replace); + const index = this.files?.findIndex((file) => file.name === search) ?? -1; + if (index > -1) { + const oldFile = this.getFile(search); + if (this.files && oldFile) { + const newFile = await this.source.getFileInfo(replace, oldFile.order); + newFile.original = oldFile.name; + this.files[index] = newFile; + return this.files[index]; + } + } else { + this.platform.user.log.warn( + "Post.replaceFile", + "metadata not found", + search, + ); + } + } + + /** + * @param name relative path in this post.source + * @returns the full path to that file + */ + getFilePath(name: string): string { + return this.source.path + "/" + name; + } + + /** + * Process a post result. Push the result to results[], + * and if not dryrun, fix dates and statusses and + * note remote id and link + * @param remoteId - the remote id of the post + * @param link - the remote link of the post + * @param result - the postresult + * @returns boolean if success + */ + + async processResult( + remoteId: string, + link: string, + result: PostResult, + ): Promise { + this.results.push(result); + + if (result.error) { + this.platform.user.log.warn( + "Post.processResult", + this.id, + "failed", + result.error, + result.response, + ); + } + + if (!result.dryrun) { + if (!result.error) { + this.remoteId = remoteId; + this.link = link; + this.status = PostStatus.PUBLISHED; + this.published = new Date(); + } else { + this.status = PostStatus.FAILED; + } + } + + await this.save(); + return result.success; + } +} diff --git a/src/models/Source.ts b/src/models/Source.ts new file mode 100644 index 0000000..4331fe5 --- /dev/null +++ b/src/models/Source.ts @@ -0,0 +1,374 @@ +import { dirname, basename, extname } from "path"; + +import sharp from "sharp"; +import Feed from "./Feed.ts"; +import { + SourceStage, + PostStatus, + FileInfo, + FileGroup, +} from "../types/index.ts"; +import Platform from "./Platform.ts"; +import Post from "./Post.ts"; +import SourceMapper from "../mappers/SourceMapper.ts"; + +/** + * Source - a folder within a feed + * + * A source represents one post on all enabled + * and applicable platforms. It is also just + * a folder on a filesystem. + * + * be sure not to do much heavy lifting in the constructor. + * source objects should be light, because they + * are often just hubs to get to posts. + */ +export default class Source { + feed: Feed; + id: string; + path: string; + stage: SourceStage; + files?: FileInfo[]; + mapper: SourceMapper; + + /** + * Dont call the constructor yourself; + * instead, call `await Source.getSource()` + * @param feed + * @param path + */ + constructor(feed: Feed, path: string) { + this.feed = feed; + this.id = this.getSourceId(path); + this.path = path; + this.mapper = new SourceMapper(this); + this.stage = this.getSourceStage(); + } + + /** + * getSourcePath + * + * Get the path for a source in a feed, based on stage and id + * @param feed - the feed this source belongs to + * @param id - the id of the source + * @param stage - the stage of the source + * @returns the path to the source + */ + public static getSourcePath( + feed: Feed, + id: string, + stage: SourceStage, + ): string { + return feed.getStagePath(stage) + "/" + id; + } + + /** + * get source id based on the path of a source + * @param path the path for the new or existing source + * @returns the id for the new or existing source + */ + public getSourceId(path: string): string { + return basename(path); // ah, simple + } + + /** + * Get the stage of this source. + * + * The stage depends on the various statusses of the posts + * in the source. The path of the source depends on the status, + * and here we just check the path to see its current status. + * @returns {SourceStage} - the status of the source + */ + public getSourceStage(): SourceStage { + const parent = dirname(this.path); + for (const stage of Object.values(SourceStage)) { + const stagePath = this.feed.getStagePath(stage); + if (parent.endsWith(stagePath)) { + return stage; + } + } + return SourceStage.UNKNOWN; + } + + /** + * getSource + * + * get a new source and do some async checks. + * @param feed - the feed this source belongs to + * @param id - the id of the source + * @param stage - optional stage to find the source in + * @returns new source object + */ + + public static async getSource( + feed: Feed, + id: string, + stage?: SourceStage, + ): Promise { + const stages = stage ? [stage] : Object.values(SourceStage); + for (const stage of stages) { + const sourcePath = Source.getSourcePath(feed, id, stage); + if (await feed.user.files.isDir(sourcePath)) { + return new Source(feed, sourcePath); + } + } + throw feed.user.log.error("getSource", "No source in stage: " + id, stage); + } + + /** + * Update the stage of a source. + * + * The stage of the source depends on the various statusses + * of the posts in the source. Post.save calls this method. + * The path of the source depends on the stage, so if + * it is updated source may move to a new location. + * @returns {SourceStage} - the new stage of the source + */ + public async updateStage(): Promise { + this.feed.user.log.trace("Source", "updateStage"); + + // check all posts to check their status + const orgStage = this.stage; + let newStage: SourceStage | undefined = undefined; + + if (this.stage === SourceStage.ARCHIVED) { + newStage = SourceStage.ARCHIVED; + } else { + const posts = await this.getPosts(); + if (posts.length === 0) { + newStage = SourceStage.INCOMING; + } else if ( + posts.every( + (post: Post) => + post.status === PostStatus.PUBLISHED || + post.status === PostStatus.CANCELED || + !post.valid, + ) + ) { + newStage = SourceStage.FINISHED; + } else if ( + posts.every( + (post: Post) => + post.status === PostStatus.UNSCHEDULED || + post.status === PostStatus.CANCELED || + !post.valid, + ) + ) { + newStage = SourceStage.PENDING; + } else if ( + posts.some((post: Post) => post.status === PostStatus.UNKNOWN) + ) { + newStage = SourceStage.INCOMING; + } + if (newStage === undefined) { + newStage = SourceStage.ACTIVE; + } + } + if (orgStage === newStage) { + this.feed.user.log.trace("Source", this.id, "updateStage", "no change"); + return this.stage; + } + + // if our stage changed, + // move this source to the new location + this.feed.user.log.trace( + "Source", + this.id, + "updateStage", + "stage changed", + orgStage, + newStage, + ); + + let newId = this.id; + if (orgStage === SourceStage.INCOMING) { + if (this.id.match(/^\d{8}-\d{6}-/)) { + newId = this.feed.user.files.slugify(this.id); + } else { + const timestamp = await this.getTimestamp(); + const date = new Date(timestamp); + const ymdhis = + date.toISOString().slice(0, 10).replace(/-/g, "") + + "-" + + date.toISOString().slice(11, 19).replace(/:/g, ""); + newId = ymdhis + "-" + this.feed.user.files.slugify(this.id); + } + } + + const newPath = Source.getSourcePath(this.feed, newId, newStage); + if (await this.feed.user.files.exists(newPath)) { + this.feed.user.log.error( + this.id, + "updateStatus", + "source already exists: " + newPath, + ); + return this.stage; + } + + // move directory + const log = await this.feed.user.files.moveDir(this.path, newPath); + for (const msg of log) { + this.feed.user.log.trace(msg); + } + + // update my id, path, stage and clear feed cache + this.id = newId; + this.path = newPath; + this.stage = newStage; + this.feed.clearCache(); + + return this.stage; + } + + /** + * Get timestamp for a source. + * + * Not all adapters support directories, so we read the timestamps + * of all files in the source and return the first one. + * @returns timestamp or zero if no files are present + */ + public async getTimestamp(): Promise { + const fileNames = await this.getFileNames(); + const allTimestamps = await Promise.all( + fileNames.map((name) => + this.feed.user.files.getTimestamp(this.path + "/" + name), + ), + ); + if (allTimestamps.length === 0) { + return 0; + } + return Math.min(...allTimestamps); + } + + /** + * Get the files in this source + * + * reads info from disk once, then caches that + * @returns array of fileinfo for all files in this source + */ + + public async getFiles(): Promise { + if (this.files !== undefined) { + return structuredClone(this.files); // todo clone where this is called + } + const fileNames = await this.getFileNames(); + this.files = []; // todo use promise.all + for (let index = 0; index < fileNames.length; index++) { + this.files.push(await this.getFileInfo(fileNames[index], index)); + } + return structuredClone(this.files); + } + + /** + * Get info for a single file + * @param name - name of the file in this source + * @param order - order to set on this file + * @returns fileinfo object for the file + */ + public async getFileInfo(name: string, order: number): Promise { + const filepath = this.path + "/" + name; + const mime = await this.feed.user.files.getMimeType(filepath); + const group = mime.split("/")[0]; + const extension = extname(name); + const size = await this.feed.user.files.getSize(filepath); + const file = { + name: name, + basename: basename(name, extension || ""), + extension: extension.substring(1), + group: Object.values(FileGroup).includes(group as FileGroup) + ? group + : FileGroup.OTHER, + mimetype: mime, + size: size, + order: order, + } as FileInfo; + if (group === FileGroup.IMAGE) { + const buffer = await this.feed.user.files.readBuffer(filepath); + const metadata = await sharp(buffer).metadata(); + file.width = metadata.width; + file.height = metadata.height; + } + return file; + } + + /** + * preparePost + * this is just an alias of Platform.preparePost(source) + */ + + public async preparePost(platform: Platform): Promise { + this.feed.user.log.trace( + "Source", + this.id, + "preparePost", + this.id, + platform.id, + ); + return await platform.preparePost(this); + } + + /** + * getPost + * this is just an alias of Platform.getPost(source) + */ + + public async getPost(platform: Platform): Promise { + this.feed.user.log.trace( + "Source", + this.id, + "getPost", + this.id, + platform.id, + ); + return await platform.getPost(this); + } + + /** + * Get multiple (prepared) posts. + * @param platforms - platforms to filter on + * @param status - post status to filter on + * @returns multiple posts + */ + + public async getPosts( + platforms?: Platform[], + status?: PostStatus, + ): Promise { + this.feed.user.log.trace("Source", this.id, "getPosts", this.id); + const posts: Post[] = []; + if (!platforms) { + platforms = this.feed.user.getPlatforms(); + } + for (const platform of platforms) { + try { + const post = await this.getPost(platform); + if (!status || status === post.status) { + posts.push(post); + } + } catch { + continue; + } + } + return posts; + } + + /** + * Get the filenames in this source; + * no directories, no hidden files + * @returns array of filenames relative to source + */ + + private async getFileNames(): Promise { + if (this.files !== undefined) { + return this.files.map((file) => file.name); + } + const files = this.feed.user.files.list(this.path).filter((file) => { + if (file.type === "directory" || file.isDirectory) return false; + const filename = basename(file.path); + if (filename.startsWith("_")) return false; + if (filename.startsWith(".")) return false; + return true; + }); + return (await files.toArray()).map((file) => basename(file.path)); + } +} diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..8e4135e --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,371 @@ +import { basename } from "path"; +import * as readline from "node:readline/promises"; + +import * as platformClasses from "../platforms/index.ts"; +import { PlatformId } from "../platforms/index.ts"; + +import Feed from "./Feed.ts"; +import Platform from "./Platform.ts"; +import GlobalFs from "../services/GlobalFs.ts"; +import { UserReport } from "../types/index.ts"; + +import UserData from "./User/UserData.ts"; +import UserFiles from "./User/UserFiles.ts"; +import UserLog from "./User/UserLog.ts"; +import UserMapper from "../mappers/UserMapper.ts"; +import { FieldMapping } from "../types/index.ts"; + +/** + * User - represents one fairpost user + * + * - with one feed + * - with zero or more platforms + * - with a private logger for this account, seperate from + * the Fairpost logger. + * - with a data store for key / value pairs + * - with a file storage for the homedir + * - with a mapper to create a dto + * + * + */ + +export default class User { + public id: string; + public homedir: string = ""; + private feed: Feed | undefined; + private platforms: + | { + [id in PlatformId]?: Platform; + } + | undefined = undefined; + public files: UserFiles; + public mapper: UserMapper; + + public data: UserData; + public log: UserLog; + + /** + * Dont call the constructor yourself; + * instead, call `await User.getUser()` + * @param id + */ + constructor(id: string) { + this.id = id; + this.homedir = ( + process.env.FAIRPOST_USER_HOMEDIR ?? "users/%user%" + ).replace("%user%", id); + + this.files = new UserFiles(this); + this.data = new UserData(this); + this.log = new UserLog(this); + this.mapper = new UserMapper(this); + } + + /** + * getUsers + * + * get all users, but do not init them all the way; + * filter them for public. This is dumb and heavy now. + * https://github.com/commonpike/fairpost/issues/135 + * @param publicOnly - wether users should be public + * @returns new user object + */ + public static async getUsers(publicOnly: boolean): Promise { + const users: User[] = []; + const globalfs = new GlobalFs(); + if (!process.env.FAIRPOST_USER_HOMEDIR) { + throw new Error("FAIRPOST_USER_HOMEDIR not set in env"); + } + const srcdir = process.env.FAIRPOST_USER_HOMEDIR.replace("%user%", ""); + const listing = await globalfs.list(srcdir).toArray(); + const ids = listing + .map((entry) => { + if (entry.isDirectory) { + return basename(entry.path); + } + }) + .filter((id) => id !== undefined); + for (const id of ids) { + const user = new User(id); + await user.files.init(); + await user.data.init(); + if ( + !publicOnly || + user.data.get("settings", "IS_PUBLIC", "false") !== "false" + ) { + users.push(user); + } + } + return users; + } + + /** + * getUser + * + * get a new user and do some async checks and loads. + * @param id - user id + * @returns new user object + */ + public static async getUser(id: string): Promise { + const user = new User(id); + await user.files.init(); + await user.data.init(); + await user.log.init(); + return user; + } + + /** + * @returns the new user + */ + + public static async createUser(newUserId: string): Promise { + if (!newUserId.match("^[a-z][a-z0-9_\\-\\.]{3,31}$")) { + throw new Error( + "invalid userid: must be between 4 and 32 long, start with a character and contain only (a-z,0-9,-,_,.)", + ); + } + const globalfs = new GlobalFs(); + + if (!process.env.FAIRPOST_USER_HOMEDIR) { + throw new Error("FAIRPOST_USER_HOMEDIR not set in env"); + } + const src = "etc/skeleton"; + const dst = process.env.FAIRPOST_USER_HOMEDIR.replace("%user%", newUserId); + if (await globalfs.exists(dst)) { + throw new Error("Homedir already exists: " + dst); + } + const log = await globalfs.copyDir(src, dst); + const user = await User.getUser(newUserId); + user.data.set("settings", "FEED_PLATFORMS", ""); + await user.data.save(); + for (const msg of log) { + user.log.info(msg); + } + user.log.info("User created: " + newUserId); + return user; + } + + /** + * getReport: return a report for this user. + * + * Generating a report may be heavy, so + * the report is updated as posts are processed + * and cached in the user data. + * @returns the report for this user. + */ + + public async getReport(): Promise { + this.log.trace("User", "getReport"); + try { + const report = this.data.getObject("cache", "report") as UserReport; + const platforms = this.getPlatforms(); + const reportedPlatforms = Object.keys(report.platforms); + if ( + !platforms.every((platform) => reportedPlatforms.includes(platform.id)) + ) { + throw this.log.error( + "User", + "getReport", + "report is missing a platform, regenerating", + ); + } + return report; + } catch { + this.log.trace("User", "getReport", "creating new report"); + const report: UserReport = { + feed: await this.getFeed().getReport(), + platforms: {}, + }; + for (const platform of this.getPlatforms()) { + report.platforms[platform.id] = await platform.getReport(); + } + await this.putReport(report); + return report; + } + } + + /** + * putReport: save an updated report + * + * The report is updated as posts are processed + * and cached in the user data. + */ + + public async putReport(report: UserReport): Promise { + this.log.trace("User", "putReport"); + this.data.setObject("cache", "report", report); + await this.data.save(); + } + + /** + * @returns the feed for this user + */ + + public getFeed(): Feed { + if (!this.feed) { + this.feed = new Feed(this); + } + return this.feed; + } + + /** + * Load all available platforms, and set + * those that are active in the settings, + * active + */ + private loadPlatforms(): void { + this.log.trace("User", "loadPlatforms"); + const platformIds = this.data + .get("settings", "FEED_PLATFORMS", "") + .split(","); + Object.values(platformClasses).forEach((platformClass) => { + if (typeof platformClass === "function") { + if (platformIds.includes(platformClass.id())) { + const platform = new platformClass(this); + platform.active = true; + if (this.platforms === undefined) { + this.platforms = {}; + } + this.platforms[platform.id] = platform; + } + } + }); + } + + /** + * Get one platform + * @param platformId - the slug of the platform + * @returns platform given by id + */ + getPlatform(platformId: PlatformId): Platform { + this.log.trace("User", "getPlatform", platformId); + if (this.platforms === undefined) { + this.loadPlatforms(); + } + let platform = this.platforms?.[platformId]; + if (platform) { + return platform; + } + + Object.values(platformClasses).forEach((platformClass) => { + if (typeof platformClass === "function") { + if (platformClass.id() === platformId) { + platform = new platformClass(this); + } + } + }); + if (platform) { + return platform; + } + throw this.log.error("Unknown platform: " + platformId); + } + + /** + * Get multiple platforms + * @param platformIds - the slug of the platform + * @returns platforms given by ids + */ + getPlatforms(platformIds?: PlatformId[]): Platform[] { + this.log.trace("User", "getPlatforms", platformIds ?? ""); + if (this.platforms === undefined) { + this.loadPlatforms(); + } + return platformIds + ? platformIds.map((platformId) => this.getPlatform(platformId)) + : Object.values(this.platforms ?? {}); + } + + /** + * Enable a platform on this user + * @param platformId + * @returns the enabled platform + */ + public async addPlatform(platformId: PlatformId): Promise { + this.log.trace("User", "addPlatform", platformId); + if ( + Object.values(PlatformId).includes(platformId) && + platformId != PlatformId.UNKNOWN + ) { + const platforms = this.data.get("settings", "FEED_PLATFORMS", ""); + const platformIds = platforms ? platforms.split(",") : []; + if (!platformIds.includes(platformId)) { + platformIds.push(platformId); + this.data.set("settings", "FEED_PLATFORMS", platformIds.join(",")); + await this.data.save(); + } + this.loadPlatforms(); + this.log.info(`Platform ${platformId} enabled for user ${this.id}`); + } else { + throw this.log.error("addPlatform: no such platform", platformId); + } + return this.getPlatform(platformId); + } + + /** + * Disable a platform on this user + * @param platformId + */ + public async removePlatform(platformId: PlatformId): Promise { + this.log.trace("User", "removePlatforms", platformId); + if ( + Object.values(PlatformId).includes(platformId) && + platformId != PlatformId.UNKNOWN + ) { + const platforms = this.data.get("settings", "FEED_PLATFORMS", ""); + const platformIds = platforms ? platforms.split(",") : []; + const index = platformIds.indexOf(platformId); + if (index !== -1) { + platformIds.splice(index, 1); + this.data.set("settings", "FEED_PLATFORMS", platformIds.join(",")); + await this.data.save(); + } + this.loadPlatforms(); + this.log.info(`Platform ${platformId} disabled for user ${this.id}`); + } else { + throw this.log.error("removePlatform: no such platform", platformId); + } + } + + /** + * @returns all data from the settings store + + public getSettings(): { [key: string]: string } { + return this.data.getStore("settings"); + } + */ + + public async promptCliFields( + fields: FieldMapping, + ): Promise<{ [key: string]: string }> { + const settings = {} as { [key: string]: string }; + const reader = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + for (const key in fields) { + const current = this.data.get( + "settings", + key, + String(fields[key].default ?? ""), + ); + const value = + (await reader.question(`${fields[key].label} ( ${current} ): `)) || + current; + settings[key] = value; + } + reader.close(); + return settings; + } + + /** + * Update settings with values from payload + * @param payload - key/value object to save under settings store + + public async putSettings(payload: { [key: string]: string }): Promise { + for (const key in payload) { + this.data.set("settings", key, payload[key]); + } + await this.data.save(); + } + */ +} diff --git a/src/models/User/UserData.ts b/src/models/User/UserData.ts new file mode 100644 index 0000000..1e59b8e --- /dev/null +++ b/src/models/User/UserData.ts @@ -0,0 +1,231 @@ +import { dirname } from "path"; +import User from "../User.ts"; + +/** + * UserData + * + * - sets and gets key / value pairs, all string. + * - uses four 'stores': + * - 'app' is typically what the admin maintains + * - 'settings' is typically what a user maintains, + * - 'auth' is what fairpost maintains and may be + * stored and encrypted somewhere else + * - 'cache' is what fairpost maintains and may be + * deleted at any time + * - each store has a backend, one of + * - 'env' is process.env (.env) + * - 'json' is json file, with one key for each store and a flat list below it + * - 'json-env' is the above json file with .env as fallback + * + * which store uses which backend should be + * set in the environment + */ + +type StorageType = "app" | "settings" | "auth" | "cache"; +enum StorageKeys { + "app" = "FAIRPOST_STORAGE_APP", + "settings" = "FAIRPOST_STORAGE_SETTINGS", + "auth" = "FAIRPOST_STORAGE_AUTH", + "cache" = "FAIRPOST_STORAGE_CACHE", +} + +export default class UserData { + jsonPath: string; + jsonData: { [store: string]: { [key: string]: string } } = {}; + user: User; + /** + * Create a new UserData. + * Dont forgt to call await init() afterwards. + * @param user + */ + constructor(user: User) { + this.user = user; + this.jsonPath = this.getEnv("app", "USER_JSONPATH", "storage.json").replace( + "%user%", + user.id, + ); + } + + public async init() { + await this.load(); + } + + public async load() { + await this.loadJson(); + } + + public async save() { + await this.saveJson(); + } + + public get(store: StorageType, key: string, def?: string): string { + const storageKey = StorageKeys[store]; + const storage = process.env[storageKey] ?? "none"; + switch (storage) { + case "env": + return this.getEnv(store, key, def); + case "json-env": + try { + return this.getJson(store, key); + } catch { + return this.getEnv(store, key, def); + } + case "json": + return this.getJson(store, key, def); + default: + throw new Error("UserData: Storage " + storage + " not implemented"); + } + } + + /*public getStore(storeName: StorageType): { [key: string]: string } { + const storageKey = StorageKeys[storeName]; + const storage = process.env[storageKey] ?? "none"; + const jsonStore = this.jsonData[storeName]; + switch (storage) { + case "json-env": + case "json": + return jsonStore; + default: + throw new Error( + "UserData.getStore: Storage " + storage + " not implemented", + ); + } + }*/ + + public getObject(store: StorageType, key: string, def?: object): object { + const value = this.get(store, key, JSON.stringify(def)); + try { + return JSON.parse(value); + } catch { + throw new Error( + "UserData.getObject: Value " + store + "." + key + " not a valid json", + ); + } + } + + private getEnv(store: StorageType, key: string, def?: string): string { + let value = process.env["FAIRPOST_" + key] ?? ""; + if (!value) { + if (def === undefined) { + throw new Error( + "UserData.getEnv: Value " + "FAIRPOST_" + key + " not found.", + ); + } + value = def; + } + return value; + } + + private getJson(store: StorageType, key: string, def?: string): string { + let value = this.jsonData[store]?.[key] ?? ""; + if (!value) { + if (def === undefined) { + throw new Error( + "UserData.getJson: Value " + store + "." + key + " not found.", + ); + } + value = def; + } + return value; + } + + public set(store: StorageType, key: string, value: string) { + const storageKey = StorageKeys[store]; + const storage = process.env[storageKey] ?? "none"; + switch (storage) { + case "env": + return this.setEnv(store, key, value); + case "json-env": + case "json": + return this.setJson(store, key, value); + default: + throw new Error("UserData: Storage " + storage + " not implemented"); + } + } + + public setObject(store: StorageType, key: string, value: object) { + return this.set(store, key, JSON.stringify(value)); + } + + private setEnv(store: StorageType, key: string, value: string) { + const ui = process.env.FAIRPOST_UI ?? "none"; + if (ui === "cli") { + console.log("Store this value in your users .env file:"); + console.log(); + console.log("FAIRPOST_" + key + "=" + value); + console.log(); + } else { + throw new Error("UserData.setEnv: UI " + ui + " not supported"); + } + } + + private setJson(store: StorageType, key: string, value: string) { + if (!(store in this.jsonData)) { + this.jsonData[store] = {}; + } + this.jsonData[store][key] = value; + // dont forget to call save() + } + + public del(store: StorageType, key: string) { + const storageKey = StorageKeys[store]; + const storage = process.env[storageKey] ?? "none"; + switch (storage) { + case "env": + return this.delEnv(store, key); + case "json-env": + case "json": + return this.delJson(store, key); + default: + throw new Error("UserData: Storage " + storage + " not implemented"); + } + } + + private delEnv(store: StorageType, key: string) { + const ui = process.env.FAIRPOST_UI ?? "none"; + if (ui === "cli") { + console.log("Remove this value from your users .env file:"); + console.log(); + console.log("FAIRPOST_" + key); + console.log(); + } else { + throw new Error("UserData.setEnv: UI " + ui + " not supported"); + } + } + + private delJson(store: StorageType, key: string) { + if (!(store in this.jsonData)) { + this.jsonData[store] = {}; + } + if (key in this.jsonData[store]) { + delete this.jsonData[store][key]; + } + // dont forget to call save() + } + + private async loadJson() { + if (await this.user.files.isFile(this.jsonPath)) { + const contents = await this.user.files.readFile(this.jsonPath); + const jsonData = JSON.parse(contents); + if (jsonData) { + this.jsonData = jsonData; + } else { + throw new Error("UserData.loadJson: cant parse " + this.jsonPath); + } + } else { + throw new Error("UserData.loadJson: cant read " + this.jsonPath); + } + } + + private async saveJson() { + if (!(await this.user.files.exists(this.jsonPath))) { + await this.user.files.mkdir(dirname(this.jsonPath)); + } + try { + const contents = JSON.stringify(this.jsonData, null, "\t"); + await this.user.files.write(this.jsonPath, contents); + } catch { + throw new Error("UserData.saveJson: cant write " + this.jsonPath); + } + } +} diff --git a/src/models/User/UserFiles.ts b/src/models/User/UserFiles.ts new file mode 100644 index 0000000..66e1a07 --- /dev/null +++ b/src/models/User/UserFiles.ts @@ -0,0 +1,208 @@ +import { basename, extname, resolve } from "path"; +import { Readable } from "stream"; + +import User from "../User.ts"; + +import { + FileStorage, + DirectoryListing, + FileContents, + StatEntry, +} from "@flystorage/file-storage"; +import { LocalStorageAdapter } from "@flystorage/local-fs"; + +/** + * UserFiles is a wrapper around flystorage, tied to a user; + */ + +export default class UserFiles { + private user: User; + public storage: FileStorage; + + constructor(user: User) { + this.user = user; + switch (process.env.FAIRPOST_FILE_SYSTEM) { + default: { + const adapter = new LocalStorageAdapter( + resolve(import.meta.dirname + "/../../../", user.homedir), + ); + this.storage = new FileStorage(adapter); + } + } + } + + public async init() { + if (!(await this.storage.directoryExists("."))) { + throw new Error("No such user: " + this.user.id); + } + } + + public async stat(path: string): Promise { + return await this.storage.stat(path); + } + + public async exists(path: string): Promise { + try { + return !!(await this.storage.stat(path)); + } catch { + return false; + } + } + + public async isDir(path: string): Promise { + try { + return await this.storage.directoryExists(path); + } catch { + return false; + } + } + public async isFile(path: string): Promise { + try { + return await this.storage.fileExists(path); + } catch { + return false; + } + } + + public list(path: string, options?: { deep?: boolean }): DirectoryListing { + return this.storage.list(path, options); + } + + public async mkdir(path: string): Promise { + return await this.storage.createDirectory(path); + } + public async read(path: string): Promise { + return await this.storage.read(path); + } + public async readFile(path: string): Promise { + return await this.storage.readToString(path); + } + public async readBuffer(path: string): Promise { + return await this.storage.readToBuffer(path); + } + public async write(path: string, contents: FileContents): Promise { + return await this.storage.write(path, contents); + } + public async copy( + src: string, + dst: string, + checkForDir = true, + ): Promise { + if (checkForDir && (await this.isDir(src))) { + await this.copyDir(src, dst); + return; + } + return await this.storage.copyFile(src, dst); + } + + public async copyDir( + sourceDir: string, + destinationDir: string, + ): Promise { + const log: string[] = []; + const createDirectoryPromises = []; + for await (const entry of this.list(sourceDir, { deep: true })) { + if (entry.type === "directory" || entry.isDirectory) { + const sourcePath = entry.path; + const relativePath = sourcePath + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("creating dir " + destinationPath); + createDirectoryPromises.push(this.mkdir(destinationPath)); + } + } + await Promise.all(createDirectoryPromises); + + const copyFilePromises = []; + for await (const entry of this.list(sourceDir, { deep: true })) { + if (entry.type === "file" || entry.isFile) { + const sourcePath = entry.path; + const relativePath = sourcePath + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("copying file " + entry.path + " -> " + destinationPath); + copyFilePromises.push(this.copy(entry.path, destinationPath, true)); + } + } + await Promise.all(copyFilePromises); + + return log; + } + + public async move( + src: string, + dst: string, + checkForDir = true, + ): Promise { + if (checkForDir && (await this.isDir(src))) { + await this.moveDir(src, dst); + return; + } + return await this.storage.moveFile(src, dst); + } + + public async moveDir( + sourceDir: string, + destinationDir: string, + ): Promise { + const log: string[] = []; + + const createDirectoryPromises = []; + for await (const item of this.list(sourceDir, { deep: true })) { + if (item.isDirectory) { + const relativePath = item.path + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("creating dir " + destinationPath); + createDirectoryPromises.push( + this.storage.createDirectory(destinationPath), + ); + } + } + await Promise.all(createDirectoryPromises); + + const moveFilePromises = []; + for await (const item of this.list(sourceDir, { deep: true })) { + if (item.isFile) { + const relativePath = item.path + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("moving file " + item.path + "," + destinationPath); + moveFilePromises.push(this.move(item.path, destinationPath, true)); + } + } + await Promise.all(moveFilePromises); + + log.push("deleting dir " + sourceDir); + await this.storage.deleteDirectory(sourceDir); + return log; + } + + public async getMimeType(path: string): Promise { + try { + return await this.storage.mimeType(path); + } catch { + return "application/unknown"; + } + } + public async getSize(path: string): Promise { + return await this.storage.fileSize(path); + } + public async getTimestamp(path: string): Promise { + return await this.storage.lastModified(path); + } + + public slugify(name: string) { + const ext = extname(name).toLowerCase(); + const base = basename(name, ext) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return base + ext; + } +} diff --git a/src/models/User/UserLog.ts b/src/models/User/UserLog.ts new file mode 100644 index 0000000..4c937b7 --- /dev/null +++ b/src/models/User/UserLog.ts @@ -0,0 +1,109 @@ +import User from "../User.ts"; + +import log4js from "log4js"; +import log4jsConfig from "../../config/log4js.json" with { type: "json" }; + +/** + * UserLog is a wrapper around Log4js, tied to a user; + * it has exceptional methods for error() and fatal in that + * they return an Error object. + */ +export default class UserLog { + private user: User; + private logger: log4js.Logger | undefined = undefined; + + /** + * Create a new UserLog. + * Dont forgt to call await init() afterwards. + * @param user + */ + constructor(user: User) { + this.user = user; + } + + public async init() { + this.logger = await this.getLogger(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public trace(...args: any[]) { + this.logger?.trace(this.user.id, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public debug(...args: any[]) { + this.logger?.debug(this.user.id, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public info(...args: any[]) { + this.logger?.info(this.user.id, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public warn(...args: any[]) { + this.logger?.warn(this.user.id, ...args); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public error(...args: any[]): Error { + this.logger?.error(this.user.id, ...args); + return new Error( + "Error: " + + "(" + + this.user.id + + ") " + + args.filter((arg) => typeof arg === "string").join("; "), + ); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public fatal(...args: any[]): Error { + this.logger?.fatal(this.user.id, ...args); + const code = parseInt(args[0]); + process.exitCode = code || 1; + return new Error( + "Fatal: " + + +"(" + + this.user.id + + ") " + + args.filter((arg) => typeof arg === "string").join("; "), + ); + } + + /** + * @returns a logger to use on this user + * + * allow cli/env to override level and console + */ + private async getLogger(): Promise { + const configFile = this.user.data.get("settings", "LOGGER_CONFIG"); + if (process.argv.includes("--verbose")) { + process.env.FAIRPOST_LOGGER_LEVEL = "TRACE"; + process.env.FAIRPOST_LOGGER_CONSOLE = "true"; + } + const level = this.user.data!.get("settings", "LOGGER_LEVEL", "INFO"); + const addConsole = + this.user.data!.get("settings", "LOGGER_CONSOLE", "false") === "true"; + + const config = (await this.user.files.isFile(configFile)) + ? JSON.parse(await this.user.files.readFile(configFile)) + : log4jsConfig; + if (!config.categories["user"]) { + throw new Error( + "Logger: Log4js category user not found in " + configFile, + ); + } + + if ( + addConsole && + !config.categories["user"]["appenders"].includes("console") + ) { + if (!config.appenders["console"]) { + config.appenders["console"] = { type: "console" }; + } + config.categories["user"]["appenders"].push("console"); + } + + log4js.configure(config); + const logger = log4js.getLogger("user"); + logger.addContext("userId", this.user.id); + logger.level = level; + return logger; + } +} diff --git a/src/platforms/AsFacebook.ts b/src/platforms/AsFacebook.ts deleted file mode 100644 index aa9c0ec..0000000 --- a/src/platforms/AsFacebook.ts +++ /dev/null @@ -1,23 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsFacebook extends Ayrshare { - slug: PlatformSlug = PlatformSlug.ASFACEBOOK; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('facebook'); - } - - async preparePost(folder: Folder): Promise { - return super.preparePost(folder); - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{},dryrun); - } - -} \ No newline at end of file diff --git a/src/platforms/AsInstagram.ts b/src/platforms/AsInstagram.ts deleted file mode 100644 index 9762cb6..0000000 --- a/src/platforms/AsInstagram.ts +++ /dev/null @@ -1,57 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; -import * as sharp from 'sharp'; - -export default class AsInstagram extends Ayrshare { - slug = PlatformSlug.ASINSTAGRAM; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('instagram'); - } - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // instagram: 1 video for reel - if (post.files.video.length) { - console.log('Removing images for instagram reel..'); - post.files.image = []; - if (post.files.video.length > 1) { - console.log('Using first video for instagram reel..'); - post.files.video = [post.files.video[0]]; - } - } - // instagram : scale images - for (const image of post.files.image) { - const metadata = await sharp(post.folder.path+'/'+image).metadata(); - if (metadata.width > 1440) { - console.log('Resizing '+image+' for instagram ..'); - await sharp(post.folder.path+'/'+image).resize({ - width: 1440 - }).toFile(post.folder.path+'/_instagram-'+image); - post.files.image.push('_instagram-'+image); - post.files.image = post.files.image.filter(file => file !== image); - } - } - // instagram: require media - if (post.files.image.length+post.files.video.length === 0) { - post.valid = false; - } - post.save(); - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{ - isVideo: post.files.video.length!==0, - instagramOptions: { - // "autoResize": true -- only enterprise plans - } - },dryrun); - } -} \ No newline at end of file diff --git a/src/platforms/AsLinkedIn.ts b/src/platforms/AsLinkedIn.ts deleted file mode 100644 index 3122587..0000000 --- a/src/platforms/AsLinkedIn.ts +++ /dev/null @@ -1,33 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsLinkedIn extends Ayrshare { - slug = PlatformSlug.ASLINKEDIN; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('linkedin'); - } - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // linkedin: max 9 media - if (post.files.video.length > 9) { - post.files.video.length = 9; - } - if (post.files.image.length + post.files.video.length > 9 ) { - post.files.image.length = Math.max(0,post.files.image.length - post.files.video.length); - } - post.save(); - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{},dryrun); - } -} \ No newline at end of file diff --git a/src/platforms/AsReddit.ts b/src/platforms/AsReddit.ts deleted file mode 100644 index 5486ac5..0000000 --- a/src/platforms/AsReddit.ts +++ /dev/null @@ -1,40 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsReddit extends Ayrshare { - slug = PlatformSlug.ASREDDIT; - SUBREDDIT: string; - - constructor() { - super(); - this.SUBREDDIT = process.env.FAYRSHARE_REDDIT_SUBREDDIT; - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('reddit'); - } - - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // reddit: max 1 image, no video - post.files.video = []; - if (post.files.image.length > 1 ) { - post.files.image.length = 1; - } - post.save(); - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{ - redditOptions: { - title: post.title, // required - subreddit: this.SUBREDDIT, // required (no "/r/" needed) - } - },dryrun); - } - -} \ No newline at end of file diff --git a/src/platforms/AsTikTok.ts b/src/platforms/AsTikTok.ts deleted file mode 100644 index afdf88e..0000000 --- a/src/platforms/AsTikTok.ts +++ /dev/null @@ -1,33 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsTikTok extends Ayrshare { - slug = PlatformSlug.ASTIKTOK; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('tiktok'); - } - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // tiktok: one video - post.files.image = []; - if (!post.files.video.length) { - post.valid = false; - } else { - post.files.video.length = 1; - } - post.save(); - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{},dryrun); - } -} \ No newline at end of file diff --git a/src/platforms/AsTwitter.ts b/src/platforms/AsTwitter.ts deleted file mode 100644 index d6814ff..0000000 --- a/src/platforms/AsTwitter.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsTwitter extends Ayrshare { - slug = PlatformSlug.ASTWITTER; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('twitter'); - } - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // twitter: no video - post.files.video = []; - // twitter: max 4 images - if (post.files.image.length>4) { - post.files.image.length=4; - } - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{},dryrun); - } - -} \ No newline at end of file diff --git a/src/platforms/AsYouTube.ts b/src/platforms/AsYouTube.ts deleted file mode 100644 index 9984c44..0000000 --- a/src/platforms/AsYouTube.ts +++ /dev/null @@ -1,40 +0,0 @@ - -import Ayrshare from "./Ayrshare"; -import { PlatformSlug } from "."; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -export default class AsYouTube extends Ayrshare { - slug = PlatformSlug.ASYOUTUBE; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_PLATFORMS.split(',').includes('youtube'); - } - - async preparePost(folder: Folder): Promise { - const post = await super.preparePost(folder); - if (post) { - // youtube: only 1 video - post.files.image = []; - if (post.files.video.length>1) { - post.files.video.length=1; - } - if (!post.files.video.length) { - post.valid = false; - } - post.save(); - } - return post; - } - - async publishPost(post: Post, dryrun:boolean = false): Promise { - return super.publishPost(post,{ - youTubeOptions: { - title: post.title, // required max 100 - visibility: "public" // optional def private - }, - },dryrun); - } - -} \ No newline at end of file diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts deleted file mode 100644 index 644ed6e..0000000 --- a/src/platforms/Ayrshare.ts +++ /dev/null @@ -1,190 +0,0 @@ - -import * as fs from 'fs'; -import * as path from 'path'; -import { randomUUID } from 'crypto'; -import { PlatformSlug } from "."; -import Platform from "../classes/Platform"; -import Folder from "../classes/Folder"; -import Post from "../classes/Post"; - -interface AyrshareResult { - success: boolean; - error?: Error; - response: {} -} - -export default abstract class Ayrshare extends Platform { - - APIKEY: string; - - requiresApproval: boolean = false; - - platforms: { - [slug in PlatformSlug]?: string - } = { - [PlatformSlug.ASYOUTUBE]: "youtube", - [PlatformSlug.ASINSTAGRAM]: "instagram", - [PlatformSlug.ASFACEBOOK]: "facebook", - [PlatformSlug.ASTWITTER]: "twitter", - [PlatformSlug.ASTIKTOK]: "tiktok", - [PlatformSlug.ASLINKEDIN]: "linkedin", - [PlatformSlug.ASREDDIT]: "reddit" - }; - - constructor() { - super(); - this.active = process.env.FAYRSHARE_AYRSHARE_ACTIVE==='true'; - this.APIKEY = process.env.FAYRSHARE_AYRSHARE_API_KEY; - } - - - async preparePost(folder: Folder): Promise { - return super.preparePost(folder); - } - - async publishPost(post: Post, platformOptions: {}, dryrun:boolean = false): Promise { - const media = [ - ...post.files.image, - ...post.files.video - ].map(f=>post.folder.path+'/'+f); - const uploads = media.length ? await this.uploadMedia(media) : []; - if (dryrun) { - post.results.push({ - date: new Date(), - dryrun: true, - uploads: uploads, - success: true, - response: {} - }); - post.save(); - return true; - } - - const result = await this.publishAyrshare(post,platformOptions, uploads); - post.results.push(result); - post.save(); - - if (!result.success) { - console.error(result.error); - } - return result.success ?? false; - } - - async uploadMedia(media: string[]): Promise { - const urls= [] as string[]; - for (const file of media) { - const buffer = fs.readFileSync(file); - const ext = path.extname(file); - const basename = path.basename(file, ext); - const uname = basename+'-'+randomUUID()+ext; - console.log('fetching uploadid...',file); - const res1 = await fetch("https://app.ayrshare.com/api/media/uploadUrl?fileName="+uname+"&contentType="+ext.substring(1), { - method: "GET", - headers: { - "Authorization": `Bearer ${this.APIKEY}` - } - }); - - if (!res1) { - return []; - } - - const data = await res1.json(); - //console.log(data); - console.log('uploading..',uname); - const uploadUrl = data.uploadUrl; - const contentType = data.contentType; - const accessUrl = data.accessUrl; - - const res2 = await fetch(uploadUrl, { - method: "PUT", - headers: { - "Content-Type": contentType, - "Authorization": `Bearer ${this.APIKEY}` - }, - body: buffer, - }); - - if (!res2) { - return []; - } - - urls.push(accessUrl.replace(/ /g, '%20')); - - } - return urls; - } - - async publishAyrshare(post: Post, platformOptions: {}, uploads: string[]): Promise { - - const result = { - success: false, - error: undefined, - response: {} - } as AyrshareResult; - - const postPlatform = this.platforms[this.slug]; - if (!postPlatform) { - result.error = new Error('No ayrshare platform associated with platform '+this.slug); - return result; - } - const body = JSON.stringify(uploads.length?{ - post: post.body, // required - platforms: [postPlatform], // required - mediaUrls: uploads, - scheduleDate: post.scheduled, - requiresApproval: this.requiresApproval, - ...platformOptions - /* - youTubeOptions: { - title: post.title, // required max 100 - visibility: "public" // opt 'private' - }, - instagramOptions: { - // "autoResize": true -- only enterprise plans - // isVideo: (this.data.type==='video'), - }, - redditOptions: { - title: this.data.title, // required - subreddit: REDDIT_SUBREDDIT, // required (no "/r/" needed) - }*/ - - }:{ - post: post.body, // required - platforms: [postPlatform], // required - scheduleDate: post.scheduled, - requiresApproval: this.requiresApproval - }); - console.log('scheduling...',postPlatform); - //console.log(body); - const res = await fetch("https://app.ayrshare.com/api/post", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.APIKEY}` - }, - body: body - }).catch(e=> { - result.error = e; - }); - - if (res && res.ok) { - //console.log(res.json()); - result.response = await res.json() as unknown as { - status?: string - }; - if (result.response['status']!=='success' && result.response['status']!=='scheduled') { - result.success = false; - result.error = new Error('bad result status: '+result.response['status']); - } else { - console.log(result); - } - return result; - } - - console.error(res); - result.success = false; - result.error = new Error('no result'); - return result; - } -} \ No newline at end of file diff --git a/src/platforms/Bluesky/Bluesky.ts b/src/platforms/Bluesky/Bluesky.ts new file mode 100644 index 0000000..2fa7090 --- /dev/null +++ b/src/platforms/Bluesky/Bluesky.ts @@ -0,0 +1,414 @@ +import { FileGroup, FieldMapping } from "../../types/index.ts"; +import Source from "../../models/Source.ts"; + +import Operator from "../../models/Operator.ts"; +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import BlueskyAuth from "./BlueskyAuth.ts"; +import { BlobRef } from "@atproto/api"; +// import { AppBskyVideoDefs, AtpAgent } from "@atproto/api"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; +import User from "../../models/User.ts"; + +/** + * Bluesky: support for bluesky platform + */ + +export default class Bluesky extends Platform { + assetsFolder = "_bluesky"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + video_max: 1, + image_max: 4, + }, + imagesize: { + max_size: 1000, + }, + }; + settings: FieldMapping = { + BLUESKY_IDENTIFIER: { + type: "string", + label: "Bluesky Identifier", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + BLUESKY_PLUGIN_SETTINGS: { + type: "json", + label: "Bluesky Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + auth: BlueskyAuth; + + constructor(user: User) { + super(user); + this.auth = new BlueskyAuth(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Bluesky connect requires a payload"); + } + await this.auth.connectApi(payload); + return await this.test(); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + this.user.log.trace("Bluesky.test"); + const agent = await this.auth.getAgent(); + return await agent.com.atproto.server.getSession(); + } + + /** @inheritdoc */ + async refresh(): Promise { + //await this.auth.refresh(); + return true; + } + + /** @inheritdoc */ + + async preparePost(source: Source): Promise { + this.user.log.trace("Bluesky.preparePost", source.id); + const post = await super.preparePost(source); + if (post) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "BLUESKY_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + + // Annimated GIF will be sent as a video. Only one animated GIF can be sent per post. + + // video + // Supported formats: MP4. + // Duration max: 4 minutes. + // Duration min: 1 second. + // Aspect ratio must be between 1:3 and 3:1. + + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Bluesky.publishPost", post.id, dryrun || ""); + + let response = { uri: "at://local/nop" } as { uri: string }; + let error = undefined as Error | undefined; + + if (post.hasFiles(FileGroup.VIDEO)) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if (post.hasFiles(FileGroup.IMAGE)) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + return post.processResult( + response.uri, + this.atUriToBskyAppUrl(response.uri), + { + date: new Date(), + dryrun: dryrun, + success: !error, + response: response, + error: error, + }, + ); + } + + /** + * post body text + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post + */ + + private async publishTextPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + uri: string; + }> { + this.user.log.trace("Blueksy.publishTextPost", post.id, dryrun || ""); + const agent = await this.auth.getAgent(); + if (dryrun) { + return { uri: "at://local/dryrun" }; + } + const response = await agent.post({ + text: post.getCompiledBody(), + createdAt: new Date().toISOString(), + }); + //if (result.validationStatus !== 'valid') { + // throw this.user.log.error(result.errors.join()); + // } + if (!response.uri || !response.cid) { + throw this.user.log.error("Invalid response", response); + } + return response; + } + + /** + * Upload a images to bluesky + * and create a post with body & images + * @param post - the post to publish + * @param dryrun - wether to actually post it + * @returns object incl uri of the created post + */ + + private async publishImagesPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + uri: string; + }> { + this.user.log.trace("Bluesky.publishImagesPost", post.id, dryrun || ""); + + const agent = await this.auth.getAgent(); + + const images = [] as { + alt: string; + image: BlobRef; + aspectRatio: { + width: number; + height: number; + }; + }[]; + + for (const image of post.getFiles(FileGroup.IMAGE).splice(0, 4)) { + const path = post.getFilePath(image.name); + images.push({ + alt: image.basename.replace(/[-_]/g, " "), + image: await this.uploadImage(path), + aspectRatio: { + width: image.width ?? 0, + height: image.height ?? 0, + }, + }); + } + + if (dryrun) { + return { uri: "at://local/dryrun" }; + } + + this.user.log.trace("Posting " + post.id + "..."); + const response = await agent.post({ + text: post.getCompiledBody(), + createdAt: new Date().toISOString(), + embed: { + $type: "app.bsky.embed.images", + images: images, + }, + }); + + //if (result.validationStatus !== 'valid') { + // throw this.user.log.error(result.errors.join()); + // } + if (!response.uri || !response.cid) { + throw this.user.log.error("Invalid response", response); + } + return response; + } + + /** + * Upload a video to bluesky + * and create a post with body & video + * @param post - the post to publish + * @param dryrun - wether to actually post it + * @returns object incl uri of the created post + */ + + private async publishVideoPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + uri: string; + }> { + this.user.log.trace("Bluesky.publishVideoPost", post.id, dryrun || ""); + const agent = await this.auth.getAgent(); + const video = post.getFiles(FileGroup.VIDEO)[0]; + const path = post.getFilePath(video.name); + const blob = await this.uploadVideo(path); + if (dryrun) { + return { uri: "at://local/dryrun" }; + } + this.user.log.trace("Posting " + post.id + "..."); + const response = await agent.post({ + text: post.getCompiledBody(), + createdAt: new Date().toISOString(), + embed: { + $type: "app.bsky.embed.video", + video: blob, + //aspectRatio: { + // width: image.width ?? 0, + // height: image.height ?? 0 + //} + }, + }); + + //if (result.validationStatus !== 'valid') { + // throw this.user.log.error(result.errors.join()); + // } + if (!response.uri || !response.cid) { + throw this.user.log.error("Invalid response", response); + } + return response; + } + + /** + * POST an image using the agents uploadBlob + * @param path - path to the file to post + * @returns blobref of the uploaded video to use in post embed + */ + private async uploadImage(path: string = ""): Promise { + this.user.log.trace("Bluesky.uploadImage", path); + const agent = await this.auth.getAgent(); + const buffer = await this.user.files.readBuffer(path); + this.user.log.trace("Uploading " + path + "..."); + try { + const { data } = await agent.uploadBlob(buffer, {}); + return data.blob; + } catch (e) { + throw this.user.log.error("Bluesky.uploadImage", "failed", e); + } + } + + /** + * POST a video to the uploadVideo endpoint using fetch + * and waiting for the response - async. + * @param path - path to the file to post + * @returns blobref of the uploaded video to use in post embed + */ + private async uploadVideo(path: string = ""): Promise { + this.user.log.trace("Bluesky.uploadVideo", path); + const agent = await this.auth.getAgent(); + + // this is fine for smaller videos + const buffer = await this.user.files.readBuffer(path); + this.user.log.trace("Uploading " + path + "..."); + try { + const { data } = await agent.uploadBlob(buffer, {}); + return data.blob; + } catch (e) { + throw this.user.log.error("Bluesky.uploadVideo", "failed", e); + } + + // below method should work for larger videos - but it fails + // later with failed XRPCError: Could not find blob + // https://docs.bsky.app/docs/tutorials/video + /* + // get a service auth + const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth( + { + aud: `did:web:${agent.dispatchUrl.host}`, + lxm: "com.atproto.repo.uploadBlob", + exp: Date.now() / 1000 + 60 * 30, // 30 minutes + }, + ); + + // prepare request + const token = serviceAuth.token; + const uploadUrl = new URL( + "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo", + ); + uploadUrl.searchParams.append("did", agent.session!.did); + uploadUrl.searchParams.append("name", path.split("/").pop()!); + + // do the request + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": await this.user.files.getMimeType(path), + "Content-Length": String(await this.user.files.getSize(path)) + }, + body: await this.user.files.readBuffer(path), + }); + + // wait for the upload to finish + const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus; + let blob: BlobRef | undefined = jobStatus.blob; + const videoAgent = new AtpAgent({ service: "https://video.bsky.app" }); + + while (!blob) { + // todo: emergency exit + const { data: status } = await videoAgent.app.bsky.video.getJobStatus( + { jobId: jobStatus.jobId }, + ); + this.user.log.trace("Bluesky.uploadImage", + status.jobStatus.state, + status.jobStatus.progress || "", + ); + if (status.jobStatus.blob) { + blob = status.jobStatus.blob; + } + // wait a second + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + // return result + return blob; + */ + } + + /** + * Converts an AT URI for a Bluesky post to a https://bsky.app. + * https://github.com/bluesky-social/atproto/discussions/2523#discussioncomment-12096639 + * @param atUri The AT URI of the post. Must be in the format at://// + * @returns The HTTPS URL to view the post on bsky.app, or null if the AT URI is invalid or not a post. + */ + private atUriToBskyAppUrl(atUri: string): string { + const regex = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/; + const match = atUri.match(regex); + + if (!match) { + return "#invalid"; // Invalid AT URI format + } + + const did = match[1]; + const collection = match[2]; + const rkey = match[3]; + + if (collection === "app.bsky.feed.post") { + return `https://bsky.app/profile/${did}/post/${rkey}`; + } else { + return "#invalid"; // Not a post record + } + } +} diff --git a/src/platforms/Bluesky/BlueskyAuth.ts b/src/platforms/Bluesky/BlueskyAuth.ts new file mode 100644 index 0000000..52af017 --- /dev/null +++ b/src/platforms/Bluesky/BlueskyAuth.ts @@ -0,0 +1,104 @@ +import { BskyAgent } from "@atproto/api"; +import User from "../../models/User.ts"; +import { encryptAESWeb, decryptAESWeb } from "../../utilities.ts"; +import * as readline from "node:readline/promises"; + +export default class BlueskyAuth { + service = "https://bsky.social"; + agent?: BskyAgent; + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Set up Bluesky platform + * + * In 2025, this uses a service, user handle, app password + */ + + public async connectCli() { + const reader = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const tokens = { + password: "", + }; + const currentid = this.user.data.get("settings", "BLUESKY_IDENTIFIER", ""); + if (!currentid) { + throw this.user.log.error( + "BlueskyAuth:connectCli - set identifier first", + ); + } + tokens.password = await reader.question(`BlueSky app password: `); + reader.close(); + await this.store(tokens); + console.log("Credentials stored."); + } + + public async connectApi(payload: { password?: string }) { + const currentid = this.user.data.get("settings", "BLUESKY_IDENTIFIER", ""); + if (!currentid) { + throw this.user.log.error( + "BlueskyAuth:connectApi - set identifier first", + ); + } + if (!payload.password) { + throw this.user.log.error( + "BlueskyAuth:connectApi - app password missing", + ); + } + await this.store({ + password: payload.password, + }); + } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + + private async store(tokens: { password: string }) { + const secret = this.user.data.get("app", "BLUESKY_CRYPT_SECRET"); + const encryptedPassword = await encryptAESWeb(tokens["password"], secret); + this.user.data.set("auth", "BLUESKY_PASSWORD", encryptedPassword); + await this.user.data.save(); + } + + /** + * Get or create a BlueSky agent + * @returns - Agent + */ + public async getAgent(): Promise { + if (this.agent) { + return this.agent; + } + this.agent = new BskyAgent({ + service: this.service, + }); + /* + dd 202507, we *could* store the jwt session to user data + and load it back into the agent; but if it expires, + we need to refresh it using the password anyway. + so lets just store the password and log in every time + */ + const identifier = this.user.data.get("settings", "BLUESKY_IDENTIFIER"); + const encryptedPassword = this.user.data.get("auth", "BLUESKY_PASSWORD"); + const secret = this.user.data.get("app", "BLUESKY_CRYPT_SECRET"); + const password = await decryptAESWeb(encryptedPassword, secret); + try { + await this.agent.login({ identifier, password }); + this.user.log.trace("BlueskyAuth", "authenticated"); + return this.agent; + } catch (error) { + console.error("Authentication failed:", error); + // error: 'AuthenticationRequired', + // headers: ... + // success: false + // status: 401 + throw error; + } + } +} diff --git a/src/platforms/Facebook/Facebook.ts b/src/platforms/Facebook/Facebook.ts new file mode 100644 index 0000000..6238c7d --- /dev/null +++ b/src/platforms/Facebook/Facebook.ts @@ -0,0 +1,270 @@ +import { basename } from "path"; +import { FileGroup, FieldMapping } from "../../types/index.ts"; + +import Source from "../../models/Source.ts"; + +import FacebookApi from "./FacebookApi.ts"; +import FacebookAuth from "./FacebookAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; + +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; + +/** + * Facebook: support for facebook platform. + * + * Uses simple graph api calls to publish. + * Adds fb specific tools to get a long lived page token, + * also use by the instagram platform. + */ +export default class Facebook extends Platform { + assetsFolder = "_facebook"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + exclusive: ["video"], + video_max: 1, + }, + imagesize: { + max_size: 4000, + }, + }; + settings: FieldMapping = { + FACEBOOK_PAGE_ID: { + type: "string", + label: "Facebook Page ID", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + FACEBOOK_PUBLISH_POSTS: { + type: "boolean", + label: "Facebook Publish posts automatically", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + }, + FACEBOOK_PLUGIN_SETTINGS: { + type: "json", + label: "Facebook Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + + api: FacebookApi; + auth: FacebookAuth; + + constructor(user: User) { + super(user); + this.auth = new FacebookAuth(user); + this.api = new FacebookApi(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + return this.auth.connectApi(payload); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + return this.api.get("me"); + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("Facebook.preparePost", source.id); + const post = await super.preparePost(source); + if (post && post.files) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "FACEBOOK_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Facebook.publishPost", post.id, dryrun); + + let response = { id: "-99" } as { id: string }; + let error = undefined as Error | undefined; + + if (post.hasFiles(FileGroup.VIDEO)) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if (post.hasFiles(FileGroup.IMAGE)) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + return post.processResult( + response.id, + "https://facebook.com/" + response.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + response: response, + error: error, + }, + ); + } + + /** + * POST body to the page/feed endpoint using json + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post + */ + private async publishTextPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + if (!dryrun) { + return (await this.api.postJson("%PAGE%/feed", { + message: post.getCompiledBody(), + published: this.user.data.get("settings", "FACEBOOK_PUBLISH_POSTS"), + })) as { id: string }; + } + return { id: "-99" }; + } + + /** + * POST images to the page/feed endpoint using json + * @param post - the post + * @param dryrun - wether to really execute + * @returns object, incl. id of the created post + */ + private async publishImagesPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + const attachments = []; + for (const image of post.getFiles(FileGroup.IMAGE)) { + attachments.push({ + media_fbid: (await this.uploadImage(post.getFilePath(image.name)))[ + "id" + ], + }); + } + + if (!dryrun) { + return (await this.api.postJson("%PAGE%/feed", { + message: post.getCompiledBody(), + published: this.user.data.get("settings", "FACEBOOK_PUBLISH_POSTS"), + attached_media: attachments, + })) as { id: string }; + } + return { id: "-99" }; + } + + /** + * POST a video to the page/videos endpoint using multipart/form-data + * + * Videos will always become a single facebook post + * when using the api. + * Uses sync posting. may take a while or timeout. + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the uploaded video + */ + private async publishVideoPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + const file = post.getFilePath(post.getFiles(FileGroup.VIDEO)[0].name); + const title = post.title; + const description = post.getCompiledBody("!title"); + + this.user.log.trace("Reading file", file); + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + + const body = new FormData(); + body.set("title", title); + body.set("description", description); + body.set( + "published", + this.user.data.get("settings", "FACEBOOK_PUBLISH_POSTS"), + ); + body.set("source", blob, basename(file)); + + if (!dryrun) { + const result = (await this.api.postForm("%PAGE%/videos", body)) as { + id: string; + }; + if (!result["id"]) { + throw this.user.log.error("No id returned when uploading video"); + } + return result; + } + return { id: "-99" }; + } + + /** + * POST an image to the page/photos endpoint using multipart/form-data + * @param file - path to the file to post + * @param published - wether the photo should be published as a single facebook post + * @returns id of the uploaded photo to use in post attachments + */ + private async uploadImage( + file: string = "", + published = false, + ): Promise<{ id: string }> { + this.user.log.trace("Reading file", file); + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + + const body = new FormData(); + body.set("published", published ? "true" : "false"); + body.set("source", blob, basename(file)); + + const result = (await this.api.postForm("%PAGE%/photos", body)) as { + id: "string"; + }; + + if (!result["id"]) { + throw this.user.log.error("No id returned when uploading photo"); + } + return result; + } +} diff --git a/src/platforms/Facebook/FacebookApi.ts b/src/platforms/Facebook/FacebookApi.ts new file mode 100644 index 0000000..a553271 --- /dev/null +++ b/src/platforms/Facebook/FacebookApi.ts @@ -0,0 +1,139 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import User from "../../models/User.ts"; + +/** + * FacebookApi: support for facebook platform. + */ + +export default class FacebookApi { + GRAPH_API_VERSION = "v22.0"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Do a GET request on the graph. + * @param endpoint - the path to call + * @param query - query string as object + */ + + public async get( + endpoint: string = "%PAGE%", + query: { [key: string]: string } = {}, + ): Promise { + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "FACEBOOK_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + this.user.log.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: + "Bearer " + this.user.data.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a Json POST request on the graph. + * @param endpoint - the path to call + * @param body - body as object + */ + + public async postJson( + endpoint: string = "%PAGE%", + body = {}, + ): Promise { + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "FACEBOOK_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: + "Bearer " + this.user.data.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), + }, + body: JSON.stringify(body), + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a FormData POST request on the graph. + * @param endpoint - the path to call + * @param body - body as object + */ + + public async postForm(endpoint: string, body: FormData): Promise { + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "FACEBOOK_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: + "Bearer " + this.user.data.get("auth", "FACEBOOK_PAGE_ACCESS_TOKEN"), + }, + body: body, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + private async handleFacebookError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } + } + throw error; + } +} diff --git a/src/platforms/Facebook/FacebookAuth.ts b/src/platforms/Facebook/FacebookAuth.ts new file mode 100644 index 0000000..7bdf0c3 --- /dev/null +++ b/src/platforms/Facebook/FacebookAuth.ts @@ -0,0 +1,293 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import OAuth2Service from "../../services/OAuth2Service.ts"; +import User from "../../models/User.ts"; +import { strict as assert } from "assert"; + +export default class FacebookAuth { + GRAPH_API_VERSION: string = "v22.0"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + async connectCli() { + const code = await this.requestCode( + this.user.data.get("app", "FACEBOOK_APP_ID"), + ); + + const accessToken = await this.exchangeCode( + code, + this.user.data.get("app", "FACEBOOK_APP_ID"), + this.user.data.get("app", "FACEBOOK_APP_SECRET"), + ); + + const pageToken = await this.getLLPageToken( + this.user.data.get("app", "FACEBOOK_APP_ID"), + this.user.data.get("app", "FACEBOOK_APP_SECRET"), + this.user.data.get("settings", "FACEBOOK_PAGE_ID"), + accessToken, + ); + + this.user.data.set("auth", "FACEBOOK_PAGE_ACCESS_TOKEN", pageToken); + await this.user.data.save(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async connectApi(payload: object) { + throw this.user.log.error("FacebookAuth:connectApi - not implemented"); + } + + protected async requestCode(clientId: string): Promise { + this.user.log.trace("FacebookAuth", "requestCode"); + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const state = String(Math.random()).substring(2); + + // create auth url + const url = new URL("https://www.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/dialog/oauth"; + const query = { + client_id: clientId, + redirect_uri: OAuth2Service.getCallbackUrl(clientHost, clientPort), + state: state, + response_type: "code", + scope: [ + "pages_manage_engagement", + "pages_manage_posts", + "pages_read_engagement", + //'pages_read_user_engagement', + "publish_video", + "business_management", + "pages_show_list", + ].join(), + }; + url.search = new URLSearchParams(query).toString(); + + const result = await OAuth2Service.requestRemotePermissions( + "Facebook", + url.href, + clientHost, + clientPort, + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; + } + + protected async exchangeCode( + code: string, + clientId: string, + clientSecret: string, + ): Promise { + this.user.log.trace("FacebookAuth", "exchangeCode"); + + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + + const tokens = (await this.get("oauth/access_token", { + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri, + })) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "FacebookAuth.exchangeCode: response is not a TokenResponse", + tokens, + ); + } + + return tokens["access_token"]; + } + + /** + * Get a long lived page access token. + * + * This method is used by getPageToken here and getPageToken + * in the instagram class, to get a long lived page token + * for either facebook or instagram + * @param appId - the app id from config + * @param appSecret - the app secret from config + * @param pageId - the pageid to get a token for + * @param userAccessToken - the short lived user token from the api + * @returns long lived page access token + */ + protected async getLLPageToken( + appId: string, + appSecret: string, + pageId: string, + userAccessToken: string, + ): Promise { + this.user.log.trace("FacebookAuth", "getLLPageToken"); + const appUserId = await this.getAppUserId(userAccessToken); + const llUserAccessToken = await this.getLLUserAccessToken( + appId, + appSecret, + userAccessToken, + ); + + const query = { + access_token: llUserAccessToken, + }; + const data = (await this.get(appUserId + "/accounts", query)) as { + data: { + id: string; + access_token: string; + }[]; + }; + + const pageData = data.data?.find((page) => page.id === pageId); + if (!pageData) { + throw this.user.log.error( + "Page " + pageId + " is not listed in the Apps accounts.", + data, + ); + } + const llPageAccessToken = pageData["access_token"]; + + if (!llPageAccessToken) { + throw this.user.log.error( + "No llPageAccessToken for page " + pageId + " in response.", + data, + ); + } + + return llPageAccessToken; + } + + /** + * Get a long lived user access token. + * @param appId - the appid from config + * @param appSecret - the app secret from config + * @param userAccessToken - the short lived user access token from api + * @returns A long lived access token + */ + private async getLLUserAccessToken( + appId: string, + appSecret: string, + userAccessToken: string, + ): Promise { + this.user.log.trace("FacebookAuth", "getLLUserAccessToken"); + const query = { + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: userAccessToken, + }; + const tokens = (await this.get( + "oauth/access_token", + query, + )) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "FacebookAuth.getLLUserAccessToken: response is not a TokenResponse", + tokens, + ); + } + return tokens["access_token"]; + } + + /** + * Get an app scoped user id + * @param accessToken - a access token returned from api + * @returns the app scoped user id ('me') + */ + private async getAppUserId(accessToken: string): Promise { + this.user.log.trace("FacebookAuth", "getAppUserId"); + const query = { + fields: "id,name", + access_token: accessToken, + }; + const data = (await this.get("me", query)) as { + id: string; + name: string; + }; + if (!data["id"]) { + throw this.user.log.error("Can not get app scoped user id.", data); + } + return data["id"]; + } + + // API implementation ------------------- + + /** + * Do a GET request on the graph. + * @param endpoint - the path to call + * @param query - query string as object + */ + + private async get( + endpoint: string = "%USER%", + query: { [key: string]: string } = {}, + ): Promise { + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + this.user.log.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleFacebookError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + private async handleFacebookError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } + } + throw error; + } +} + +interface TokenResponse { + access_token: string; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("access_token" in tokens); + } catch { + return false; + } + return true; +} diff --git a/src/platforms/Instagram/Instagram.ts b/src/platforms/Instagram/Instagram.ts new file mode 100644 index 0000000..e27c439 --- /dev/null +++ b/src/platforms/Instagram/Instagram.ts @@ -0,0 +1,549 @@ +import { basename } from "path"; +import { FileGroup, FieldMapping } from "../../types/index.ts"; + +import Source from "../../models/Source.ts"; + +import InstagramApi from "./InstagramApi.ts"; +import InstagramAuth from "./InstagramAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; + +/** + * Instagram: support for instagram platform. + * + * Uses simple graph api calls to publish. + * Uses fb specific tools to get a long lived page token, + * also uses facebook calls to upload files + */ +export default class Instagram extends Platform { + assetsFolder = "_instagram"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + total_max: 10, + }, + imagesize: { + min_width: 320, + max_width: 1440, + min_ratio: 0.8, + max_ratio: 1.91, + max_size: 8000, + }, + }; + settings: FieldMapping = { + INSTAGRAM_USER_ID: { + type: "string", + label: "Instagram User ID", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + INSTAGRAM_PAGE_ID: { + type: "string", + label: "Instagram/Facebook Page ID", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + INSTAGRAM_PLUGIN_SETTINGS: { + type: "json", + label: "Instagram Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + api: InstagramApi; + auth: InstagramAuth; + + pollingDelay = 2500; + pollingLimit = 20; + + constructor(user: User) { + super(user); + this.auth = new InstagramAuth(user); + this.api = new InstagramApi(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + return this.auth.connectApi(payload); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + return this.api.get("me"); + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("Instagram.preparePost", source.id); + const post = await super.preparePost(source); + if (post && post.files) { + // instagram: require media + if ( + post.getFiles(FileGroup.IMAGE).length + + post.getFiles(FileGroup.VIDEO).length === + 0 + ) { + post.valid = false; + } + if (post.valid) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "INSTAGRAM_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + } + + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Instagram.publishPost", post.id, dryrun); + + let response = { id: "-99" } as { id: string; permalink?: string }; + let error = undefined as Error | undefined; + + if ( + post.getFiles(FileGroup.VIDEO).length === 1 && + !post.hasFiles(FileGroup.IMAGE) + ) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if ( + post.getFiles(FileGroup.IMAGE).length === 1 && + !post.hasFiles(FileGroup.VIDEO) + ) { + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishMixedPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + if (!error && !dryrun) { + try { + const details = await this.api.get(response.id, { + fields: "id,media_type,permalink,thumbnail_url,timestamp,username", + }); + response = { ...response, ...details }; + } catch (e) { + error = e as Error; + } + } + + return post.processResult(response.id, response.permalink ?? "#unknown", { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }); + } + + /** + * Publish a single photo + * + * Upload a photo to facebook, use the largest derivate + * to put in a single container and publish that + * @param post - the post + * @param dryrun - wether to actually post it + * @returns id of the published container + */ + private async publishImagePost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + this.user.log.trace("publishImagePost", post.id, dryrun); + const file = post.getFilePath(post.getFiles(FileGroup.IMAGE)[0].name); + const caption = post.getCompiledBody(); + const photoId = (await this.uploadImage(file))["id"]; + const photoLink = await this.getImageLink(photoId); + const container = (await this.api.postJson("%USER%/media", { + image_url: photoLink, + caption: caption, + })) as { id: string }; + if (!container?.id) { + throw this.user.log.error( + "No id returned for container for " + file, + container, + ); + } + + // wait for ready + try { + await this.checkPostStatus(container.id); + } catch (e) { + throw this.user.log.error(e); + } + + if (!dryrun) { + const response = (await this.api.postJson("%USER%/media_publish", { + creation_id: container.id, + })) as { id: string }; + if (!response?.id) { + throw this.user.log.error( + "No id returned for igMedia for " + file, + response, + ); + } + return response; + } + + return { id: "-99" }; + } + + /** + * Publish a single video + * + * Upload a video to facebook, use the derivate + * to put in a single container and publish that + * @param post + * @param dryrun - wether to actually post it + * @returns id of the published container + */ + private async publishVideoPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + this.user.log.trace("publishVideoPost", post.id, dryrun); + const file = post.getFilePath(post.getFiles(FileGroup.VIDEO)[0].name); + const caption = post.getCompiledBody(); + const videoId = (await this.uploadVideo(file))["id"]; + const videoLink = await this.getVideoLink(videoId); + const container = (await this.api.postJson("%USER%/media", { + media_type: "REELS", + video_url: videoLink, + caption: caption, + })) as { id: string }; + if (!container?.id) { + throw this.user.log.error( + "No id returned for container for " + file, + container, + ); + } + + // wait for ready + try { + await this.checkPostStatus(container.id); + } catch (e) { + throw this.user.log.error(e); + } + + if (!dryrun) { + const response = (await this.api.postJson("%USER%/media_publish", { + creation_id: container.id, + })) as { id: string }; + if (!response?.id) { + throw this.user.log.error( + "No id returned for igMedia for " + file, + response, + ); + } + return response; + } + + return { id: "-99" }; + } + + /** + * Publish a caroussel + * + * Upload a videos and photos to facebook, use the derivates + * to put in a single container and publish that + * @param post - the post to publish + * @param dryrun - wether to actually post it + * @returns id of the published container + */ + private async publishMixedPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + this.user.log.trace("publishMixedPost", post.id, dryrun); + const uploadIds = [] as string[]; + + for (const file of post.getFiles(FileGroup.VIDEO, FileGroup.IMAGE)) { + if (file.group === "video") { + this.user.log.trace("publishMixedPost", "Processing video", file.name); + const videoId = (await this.uploadVideo(post.getFilePath(file.name)))[ + "id" + ]; + const videoLink = await this.getVideoLink(videoId); + uploadIds.push( + ( + (await this.api.postJson("%USER%/media", { + media_type: "REELS", + is_carousel_item: true, + video_url: videoLink, + })) as { id: string } + )["id"], + ); + } + if (file.group === "image") { + this.user.log.trace("publishMixedPost", "Processing image", file.name); + const photoId = (await this.uploadImage(post.getFilePath(file.name)))[ + "id" + ]; + const photoLink = await this.getImageLink(photoId); + uploadIds.push( + ( + (await this.api.postJson("%USER%/media", { + is_carousel_item: true, + image_url: photoLink, + })) as { id: string } + )["id"], + ); + } + } + + // create carousel + this.user.log.trace("publishMixedPost", "Preparing carousel", uploadIds); + const container = (await this.api.postJson("%USER%/media", { + media_type: "CAROUSEL", + caption: post.getCompiledBody(), + children: uploadIds.join(","), + })) as { + id: string; + }; + if (!container["id"]) { + throw this.user.log.error( + "No id returned for carroussel container ", + container, + ); + } + + // wait for ready + try { + await this.checkPostStatus(container.id); + } catch (e) { + throw this.user.log.error(e); + } + + // publish carousel + if (!dryrun) { + this.user.log.trace( + "publishMixedPost", + "Publishing carousel", + container["id"], + ); + const response = (await this.api.postJson("%USER%/media_publish", { + creation_id: container["id"], + })) as { + id: string; + }; + if (!response["id"]) { + throw this.user.log.error( + "No id returned for igMedia for carroussel", + response, + ); + } + + return response; + } + + return { id: "-99" }; + } + + /** + * POST an image to the facebook page/photos endpoint + * @param file - path to the file to post + * @returns id of the uploaded photo to use in post attachments + */ + private async uploadImage(file: string = ""): Promise<{ id: string }> { + this.user.log.trace("Reading file", file); + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + + const body = new FormData(); + body.set("published", "false"); + body.set("source", blob, basename(file)); + + const result = (await this.api.postForm("%PAGE%/photos", body)) as { + id: "string"; + }; + + if (!result["id"]) { + throw this.user.log.error("No id returned after uploading photo " + file); + } + return result; + } + + /** + * Get a link to an uploaded facebook photo + * @param id - id of the uploaded photo + * @returns link to the largest derivate of that photo to use in post attachments + */ + private async getImageLink(id: string): Promise { + // get photo derivatives + const photoData = (await this.api.get(id, { + fields: "link,images,picture", + })) as { + link: string; + images: { + width: number; + height: number; + source: string; + }[]; + picture: string; + }; + if (!photoData.images?.length) { + throw this.user.log.error("No derivates found for photo " + id); + } + + // find largest derivative + const largestPhoto = photoData.images?.reduce(function (prev, current) { + return prev && prev.width > current.width ? prev : current; + }); + if (!largestPhoto["source"]) { + throw this.user.log.error( + "Largest derivate for photo " + id + " has no source", + ); + } + return largestPhoto["source"]; + } + + /** + * POST an video to the facebook page/videos endpoint + * @param file - path to the file to post + * @returns id of the uploaded video to use in post attachments + */ + + private async uploadVideo(file: string): Promise<{ id: string }> { + this.user.log.trace("Reading file", file); + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + + const body = new FormData(); + body.set("title", "Fairpost temp instagram upload"); + body.set("published", "false"); + body.set("source", blob, basename(file)); + + const result = (await this.api.postForm("%PAGE%/videos", body)) as { + id: string; + }; + + if (!result["id"]) { + throw this.user.log.error("No id returned when uploading video"); + } + return result; + } + + /** + * Get a link to an uploaded facebook video, polling until it is ready + * @param id - id of the uploaded video + * @returns link to the video to use in post attachments + */ + + private async getVideoLink(id: string): Promise { + const api = this.api; + const delay = this.pollingDelay; + const limit = this.pollingLimit; + let counter = 0; + return new Promise((resolve, reject) => { + const poll = async () => { + counter++; + this.user.log.trace("getVideoLink", "Polling video source " + counter); + const videoData = (await api.get(id, { + fields: "permalink_url,source", + })) as { + permalink_url: string; + source: string; + }; + if (videoData.source) { + this.user.log.trace( + "getVideoLink", + "Video source ready", + videoData.source, + ); + resolve(videoData.source); + } else { + if (counter < limit) { + setTimeout(poll, delay); + } else { + reject("getVideoLink: Failed after max polls " + counter); + } + } + }; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + poll(); + }); + } + + /** + * Waiting for the post status, polling until it is FINISHED + * @param id - ig container id + * @returns link to the video to use in post attachments + */ + + private async checkPostStatus(id: string): Promise { + const api = this.api; + const delay = this.pollingDelay; + const limit = this.pollingLimit; + let counter = 0; + return new Promise((resolve, reject) => { + const poll = async () => { + counter++; + this.user.log.trace( + "checkPostStatus", + "Polling post status " + counter, + ); + const response = (await api.get(id, { + fields: "status_code", + })) as { + status_code: string; + }; + if (response.status_code === "FINISHED") { + this.user.log.trace("checkPostStatus", "Post status FINISHED"); + resolve(true); + } else if (response.status_code === "IN_PROGRESS") { + if (counter < limit) { + setTimeout(poll, delay); + } else { + reject("checkPostStatus: Failed after max polls " + counter); + } + } else { + reject("checkPostStatus: Failed with status " + response.status_code); + console.log(response); + } + }; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + poll(); + }); + } +} diff --git a/src/platforms/Instagram/InstagramApi.ts b/src/platforms/Instagram/InstagramApi.ts new file mode 100644 index 0000000..e8fd8ee --- /dev/null +++ b/src/platforms/Instagram/InstagramApi.ts @@ -0,0 +1,161 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import User from "../../models/User.ts"; + +/** + * InstagramApi: support for instagram platform. + */ + +export default class InstagramApi { + GRAPH_API_VERSION = "v22.0"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Do a GET request on the graph. + * @param endpoint - the path to call + * @param query - querystring as object + * @returns parsed response + */ + + public async get( + endpoint: string = "%USER%", + query: { [key: string]: string } = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + this.user.data.get("settings", "INSTAGRAM_USER_ID"), + ); + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + const accessToken = this.user.data.get( + "auth", + "INSTAGRAM_PAGE_ACCESS_TOKEN", + ); + this.user.log.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: accessToken + ? { + Accept: "application/json", + Authorization: "Bearer " + accessToken, + } + : { + Accept: "application/json", + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a Json POST request on the graph. + * @param endpoint - the path to call + * @param body - body as object + * @returns the parsed response as object + */ + + public async postJson( + endpoint: string = "%USER%", + body = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + this.user.data.get("settings", "INSTAGRAM_USER_ID"), + ); + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: + "Bearer " + this.user.data.get("auth", "INSTAGRAM_PAGE_ACCESS_TOKEN"), + }, + body: JSON.stringify(body), + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a FormData POST request on the graph. + * @param endpoint - the path to call + * @param body - body as object + * @returns the parsed response as object + */ + + public async postForm(endpoint: string, body: FormData): Promise { + endpoint = endpoint.replace( + "%USER%", + this.user.data.get("settings", "INSTAGRAM_USER_ID"), + ); + endpoint = endpoint.replace( + "%PAGE%", + this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: + "Bearer " + this.user.data.get("auth", "INSTAGRAM_PAGE_ACCESS_TOKEN"), + }, + body: body, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleInstagramError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + private async handleInstagramError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.error) { + error.message += + ": " + + error.responseData.error.type + + " (" + + error.responseData.error.code + + "/" + + (error.responseData.error.error_subcode || "0") + + "): " + + error.responseData.error.message; + } + } + throw error; + } +} diff --git a/src/platforms/Instagram/InstagramAuth.ts b/src/platforms/Instagram/InstagramAuth.ts new file mode 100644 index 0000000..a9b5ae5 --- /dev/null +++ b/src/platforms/Instagram/InstagramAuth.ts @@ -0,0 +1,84 @@ +import FacebookAuth from "../Facebook/FacebookAuth.ts"; +import OAuth2Service from "../../services/OAuth2Service.ts"; +import User from "../../models/User.ts"; + +export default class InstagramAuth extends FacebookAuth { + constructor(user: User) { + super(user); + } + + async connect() { + const code = await this.requestCode( + this.user.data.get("app", "INSTAGRAM_APP_ID"), + ); + + const accessToken = await this.exchangeCode( + code, + this.user.data.get("app", "INSTAGRAM_APP_ID"), + this.user.data.get("app", "INSTAGRAM_APP_SECRET"), + ); + + const pageToken = await this.getLLPageToken( + this.user.data.get("app", "INSTAGRAM_APP_ID"), + this.user.data.get("app", "INSTAGRAM_APP_SECRET"), + this.user.data.get("settings", "INSTAGRAM_PAGE_ID"), + accessToken, + ); + + this.user.data.set("auth", "INSTAGRAM_PAGE_ACCESS_TOKEN", pageToken); + await this.user.data.save(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async connectApi(payload: object) { + throw this.user.log.error("InstagramAuth:connectApi - not implemented"); + } + + protected async requestCode(clientId: string): Promise { + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const state = String(Math.random()).substring(2); + + // create auth url + const url = new URL("https://www.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/dialog/oauth"; + const query = { + client_id: clientId, + redirect_uri: OAuth2Service.getCallbackUrl(clientHost, clientPort), + state: state, + response_type: "code", + scope: [ + "pages_manage_engagement", + "pages_manage_posts", + "pages_read_engagement", + //'pages_read_user_engagement', + "publish_video", + "business_management", + "instagram_basic", + "instagram_content_publish", + ].join(), + }; + url.search = new URLSearchParams(query).toString(); + + const result = await OAuth2Service.requestRemotePermissions( + "Instagram", + url.href, + clientHost, + clientPort, + ); + + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; + } +} diff --git a/src/platforms/LinkedIn/LinkedIn.ts b/src/platforms/LinkedIn/LinkedIn.ts new file mode 100644 index 0000000..3ce6bc2 --- /dev/null +++ b/src/platforms/LinkedIn/LinkedIn.ts @@ -0,0 +1,549 @@ +import { FileGroup, FieldMapping } from "../../types/index.ts"; +import Source from "../../models/Source.ts"; +import { handleApiError, handleEmptyResponse } from "../../utilities.ts"; + +import LinkedInApi from "./LinkedInApi.ts"; +import LinkedInAuth from "./LinkedInAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; + +export default class LinkedIn extends Platform { + assetsFolder = "_linkedin"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + exclusive: ["video"], + video_max: 1, + }, + imagesize: { + max_size: 5000, + }, + }; + settings: FieldMapping = { + LINKEDIN_COMPANY_ID: { + type: "string", + label: "LinkedIn Company ID", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + LINKEDIN_PLUGIN_SETTINGS: { + type: "json", + label: "LinkedIn Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + api: LinkedInApi; + auth: LinkedInAuth; + + POST_AUTHOR = ""; + POST_VISIBILITY = "PUBLIC"; // CONNECTIONS|PUBLIC|LOGGEDIN|CONTAINER + POST_DISTRIBUTION = { + feedDistribution: "MAIN_FEED", // NONE|MAINFEED|CONTAINER_ONLY + targetEntities: [], + thirdPartyDistributionChannels: [], + }; + POST_NORESHARE = false; + + constructor(user: User) { + super(user); + this.api = new LinkedInApi(user); + this.auth = new LinkedInAuth(user); + this.mapper = new PlatformMapper(this); + this.POST_AUTHOR = + "urn:li:organization:" + + this.user.data.get("settings", "LINKEDIN_COMPANY_ID", ""); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + const result = await this.auth.connectApi(payload); + const ready = "ready" in result && result.ready; + if (!ready) return result; + return { + ...result, + test: await this.test(), + }; + } + + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + return this.getProfile(); + } + + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("LinkedIn.preparePost", source.id); + const post = await super.preparePost(source); + if (post) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "LINKEDIN_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("LinkedIn.publishPost", post.id, dryrun); + + let response = { id: "-99" } as { + id?: string; + headers?: { [key: string]: string }; + }; + let error = undefined as Error | undefined; + + if (post.hasFiles(FileGroup.VIDEO)) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if (post.getFiles(FileGroup.IMAGE).length > 1) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if (post.getFiles(FileGroup.IMAGE).length === 1) { + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + return post.processResult( + response.id as string, + "https://www.linkedin.com/feed/update/" + response.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); + } + + // Platform API Specific + + /** + * GET part of the profile + * @returns object, incl. some ids and names + */ + private async getProfile() { + const me = (await this.api.get("me")) as { + id: string; + localizedFirstName: string; + localizedLastName: string; + localizedHeadline: string; + vanityName: string; + }; + if (!me) return false; + return { + id: me["id"], + name: me["localizedFirstName"] + " " + me["localizedLastName"], + headline: me["localizedHeadline"], + alias: me["vanityName"], + }; + } + + /** + * POST title & body to the posts endpoint using json + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishTextPost(post: Post, dryrun: boolean = false) { + this.user.log.trace("LinkedIn.publishTextPost"); + const body = { + author: this.POST_AUTHOR, + commentary: post.getCompiledBody(), + visibility: this.POST_VISIBILITY, + distribution: this.POST_DISTRIBUTION, + lifecycleState: "PUBLISHED", + isReshareDisabledByAuthor: this.POST_NORESHARE, + }; + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; + } + + /** + * POST title & body & image to the posts endpoint using json + * + * uploads image using a leash + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishImagePost(post: Post, dryrun: boolean = false) { + this.user.log.trace("LinkedIn.publishImagePost"); + const title = post.title; + const image = post.getFilePath(post.getFiles(FileGroup.IMAGE)[0].name); + const leash = await this.getImageLeash(); + await this.uploadImage(leash.value.uploadUrl, image); + // TODO: save headers[etag] .. + // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#sample-response-4 + const body = { + author: this.POST_AUTHOR, + commentary: post.getCompiledBody(), + visibility: this.POST_VISIBILITY, + distribution: this.POST_DISTRIBUTION, + content: { + media: { + title: title, + id: leash.value.image, + }, + }, + lifecycleState: "PUBLISHED", + isReshareDisabledByAuthor: this.POST_NORESHARE, + }; + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; + } + + /** + * POST title & body to the posts endpoint using json + * + * uploads images using a leash + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + + private async publishImagesPost(post: Post, dryrun: boolean = false) { + this.user.log.trace("LinkedIn.publishImagesPost"); + const images = post + .getFiles(FileGroup.IMAGE) + .map((image) => post.getFilePath(image.name)); + const imageIds = []; + for (const image of images) { + const leash = await this.getImageLeash(); + await this.uploadImage(leash.value.uploadUrl, image); + // TODO: save headers[etag] .. + // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/videos-api?view=li-lms-2023-10&tabs=http#sample-response-4 + imageIds.push(leash.value.image); + } + + const body = { + author: this.POST_AUTHOR, + commentary: post.getCompiledBody(), + visibility: this.POST_VISIBILITY, + distribution: this.POST_DISTRIBUTION, + lifecycleState: "PUBLISHED", + isReshareDisabledByAuthor: this.POST_NORESHARE, + content: { + multiImage: { + images: imageIds.map((id) => { + return { + altText: "", + id: id, + }; + }), + }, + }, + }; + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; + } + + /** + * POST title & body & video to the posts endpoint using json + * + * untested. + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishVideoPost(post: Post, dryrun: boolean = false) { + this.user.log.trace("LinkedIn.publishVideoPost"); + + const title = post.title; + const video = post.getFilePath(post.getFiles(FileGroup.VIDEO)[0].name); + + const leash = await this.getVideoLeash(video); + + if (leash.value.uploadInstructions.length === 1) { + const chunkId = await this.uploadVideo( + leash.value.uploadInstructions[0].uploadUrl, + video, + ); + await this.uploadVideoFinish(leash.value.video, leash.value.uploadToken, [ + chunkId, + ]); + } else { + const chunkIds = await this.uploadVideoChunks( + leash.value.uploadInstructions.map((i) => { + return { + url: i.uploadUrl, + start: i.firstByte, + end: i.lastByte, + }; + }), + video, + ); + await this.uploadVideoFinish( + leash.value.video, + leash.value.uploadToken, + chunkIds, + ); + } + + const body = { + author: this.POST_AUTHOR, + commentary: post.getCompiledBody(), + visibility: this.POST_VISIBILITY, + distribution: this.POST_DISTRIBUTION, + content: { + media: { + title: title, + id: leash.value.video, + }, + }, + lifecycleState: "PUBLISHED", + isReshareDisabledByAuthor: this.POST_NORESHARE, + }; + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; + } + + /** + * Get a leash to upload an image + * @returns object, incl. uploadUrl + */ + + private async getImageLeash(): Promise<{ + value: { + uploadUrlExpiresAt: number; + uploadUrl: string; + image: string; + }; + }> { + this.user.log.trace("LinkedIn.getImageLeash"); + const response = (await this.api.postJson( + "images?action=initializeUpload", + { + initializeUploadRequest: { + owner: this.POST_AUTHOR, + }, + }, + )) as { + value: { + uploadUrlExpiresAt: number; + uploadUrl: string; + image: string; + }; + }; + if (!response.value) { + throw this.user.log.error("LinkedIn.getImageUploadLease: Bad response"); + } + return response; + } + + /** + * Upload an image file to an url + * @param leashUrl + * @param file + * @returns empty + */ + private async uploadImage(leashUrl: string, file: string) { + this.user.log.trace("LinkedIn.uploadImage"); + const rawData = await this.user.files.readBuffer(file); + this.user.log.trace("PUT", leashUrl); + const accessToken = this.user.data.get("auth", "LINKEDIN_ACCESS_TOKEN"); + return await fetch(leashUrl, { + method: "PUT", + headers: { + Authorization: "Bearer " + accessToken, + }, + body: rawData, + }) + .then((res) => handleEmptyResponse(res)) + .catch((err) => this.api.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Get a leash to upload an video + * @param file + * @returns object, incl. uploadUrl + */ + private async getVideoLeash(file: string): Promise<{ + value: { + uploadUrlsExpireAt: number; + video: string; + uploadInstructions: { + uploadUrl: string; + lastByte: number; + firstByte: number; + }[]; + uploadToken: string; + }; + }> { + this.user.log.trace("LinkedIn.getVideoLeash"); + const size = await this.user.files.getSize(file); + const response = (await this.api.postJson( + "videos?action=initializeUpload", + { + initializeUploadRequest: { + owner: this.POST_AUTHOR, + fileSizeBytes: size, + uploadCaptions: false, + uploadThumbnail: false, + }, + }, + )) as { + value: { + uploadUrlsExpireAt: number; + video: string; + uploadInstructions: { + uploadUrl: string; + lastByte: number; + firstByte: number; + }[]; + uploadToken: string; + }; + }; + if (!response.value) { + throw this.user.log.error("LinkedIn.getVideoUploadLease: Bad response"); + } + return response; + } + + /** + * Upload a video file to an url + * @param leashUrl + * @param file + * @returns string : chunkId + */ + private async uploadVideo(leashUrl: string, file: string): Promise { + this.user.log.trace("LinkedIn.uploadVideo"); + const rawData = await this.user.files.readBuffer(file); + this.user.log.trace("PUT", leashUrl); + const result = (await fetch(leashUrl, { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + }, + body: rawData, + }) + .then((res) => handleEmptyResponse(res, true)) + .catch((err) => this.api.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user))) as { + headers: { + etag: string; + }; + }; + return result.headers.etag; + } + + /** + * Upload a video file in chunks + * @param leashes + * @param file + * @returns array of chunkIds + */ + + private async uploadVideoChunks( + leashes: { + url: string; + start: number; + end: number; // exclusive + }[], + file: string, + ): Promise { + this.user.log.trace("LinkedIn.uploadVideoChunks"); + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + const results = []; + for (const leash of leashes) { + const chunk = blob.slice(leash.start, leash.end + 1); + this.user.log.trace("PUT", leash.url, leash.start, leash.end + 1); + results.push( + (await fetch(leash.url, { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + }, + body: chunk, + }) + .then((res) => handleEmptyResponse(res, true)) + .catch((err) => this.api.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user))) as { + headers: { + etag: string; + }; + }, + ); + } + return results.map((r) => r.headers.etag); + } + + private async uploadVideoFinish( + videoId: string, + uploadToken: string, + chunkIds: string[], + ) { + this.user.log.trace("LinkedIn.uploadVideoFinish"); + return await this.api.postJson( + "videos?action=finalizeUpload", + { + finalizeUploadRequest: { + video: videoId, + uploadToken: uploadToken, + uploadedPartIds: chunkIds, + }, + }, + true, + ); + } +} diff --git a/src/platforms/LinkedIn/LinkedInApi.ts b/src/platforms/LinkedIn/LinkedInApi.ts new file mode 100644 index 0000000..7786b93 --- /dev/null +++ b/src/platforms/LinkedIn/LinkedInApi.ts @@ -0,0 +1,139 @@ +import { + ApiResponseError, + handleApiError, + handleEmptyResponse, + handleJsonResponse, +} from "../../utilities.ts"; + +import User from "../../models/User.ts"; + +/** + * LinkedInApi: support for linkedin platform. + */ + +export default class LinkedInApi { + LGC_API_VERSION = "v2"; + API_VERSION = "202307"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Do a GET request on the api. + * @param endpoint - the path to call + * @param query - query string as object + */ + + public async get( + endpoint: string, + query: { [key: string]: string } = {}, + ): Promise { + // nb this is the legacy format + const url = new URL("https://api.linkedin.com"); + url.pathname = this.LGC_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + + const accessToken = this.user.data.get("auth", "LINKEDIN_ACCESS_TOKEN"); + + this.user.log.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Connection: "Keep-Alive", + Authorization: "Bearer " + accessToken, + "User-Agent": this.user.data.get("app", "OAUTH_USERAGENT"), + }, + }) + .then((res) => handleJsonResponse(res, true)) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a json POST request on the api. + * @param endpoint - the path to call + * @param body - body as object + */ + + public async postJson( + endpoint: string, + body = {}, + expectEmptyResponse = false, + ): Promise { + const url = new URL("https://api.linkedin.com"); + + const [pathname, search] = endpoint.split("?"); + url.pathname = "rest/" + pathname; + if (search) { + url.search = search; + } + const accessToken = this.user.data.get("auth", "LINKEDIN_ACCESS_TOKEN"); + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "Linkedin-Version": this.API_VERSION, + Authorization: "Bearer " + accessToken, + }, + body: JSON.stringify(body), + }) + .then((res) => + expectEmptyResponse + ? handleEmptyResponse(res, true) + : handleJsonResponse(res, true), + ) + .then((res) => { + const linkedinRes = res as { + id?: string; + headers?: { + "x-restli-id"?: string; + "x-linkedin-id"?: string; + }; + }; + if (!linkedinRes["id"] && "headers" in linkedinRes) { + if (linkedinRes.headers?.["x-restli-id"]) { + linkedinRes["id"] = linkedinRes.headers["x-restli-id"]; + } else if (linkedinRes.headers?.["x-linkedin-id"]) { + linkedinRes["id"] = linkedinRes.headers["x-linkedin-id"]; + } + } + return linkedinRes; + }) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + public async handleLinkedInError(error: ApiResponseError): Promise { + if (error.responseData) { + error.message += + " (" + + error.responseData.status + + "/" + + error.responseData.serviceErrorCode + + ") " + + error.responseData.message; + } + if ( + error.response?.headers && + "x-linkedin-error-response" in error.response.headers + ) { + error.message += + " - " + error.response.headers["x-linkedin-error-response"]; + } + + throw error; + } +} diff --git a/src/platforms/LinkedIn/LinkedInAuth.ts b/src/platforms/LinkedIn/LinkedInAuth.ts new file mode 100644 index 0000000..86afe1d --- /dev/null +++ b/src/platforms/LinkedIn/LinkedInAuth.ts @@ -0,0 +1,251 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import OAuth2Service from "../../services/OAuth2Service.ts"; +import User from "../../models/User.ts"; +import { strict as assert } from "assert"; + +export default class LinkedInAuth { + API_VERSION = "v2"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Set up LinkedIn platform + */ + async connectCli() { + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + const code = await this.requestCliCode(redirectUri); + const tokens = await this.exchangeCode(code, redirectUri); + await this.store(tokens); + } + + async connectApi(payload: { + state?: string; + redirect_uri?: string; + code?: string; + error?: string; + error_uri?: string; + error_description?: string; + }): Promise<{ url?: string; ready?: boolean }> { + if (payload["error"]) { + const msg = payload["error"] + " - " + payload["error_description"]; + throw this.user.log.error(msg, payload); + } + if (!payload.redirect_uri) { + throw this.user.log.error( + "LinkedInAuth.connect: Invalid payload", + payload, + ); + } + if (!payload.code) { + return { + url: this.getRequestUrl(payload.redirect_uri, payload.state), + }; + } + const tokens = await this.exchangeCode(payload.code, payload.redirect_uri); + await this.store(tokens); + return { + ready: true, + }; + } + + /** + * Get oath2 url to request a code + * @param redirectUri + * @param state + * @returns - string + */ + private getRequestUrl(redirectUri: string, state?: string): string { + const clientId = this.user.data.get("app", "LINKEDIN_CLIENT_ID"); + const url = new URL("https://www.linkedin.com"); + url.pathname = "oauth/" + this.API_VERSION + "/authorization"; + const query = { + client_id: clientId, + redirect_uri: redirectUri, + ...(state ? { state: state } : {}), + response_type: "code", + duration: "permanent", + scope: [ + "r_basicprofile", + "w_member_social", + "w_organization_social", + ].join(" "), + }; + url.search = new URLSearchParams(query).toString(); + return url.href; + } + + /** + * Request remote code using OAuth2Service as a local server + * @param redirectUri + * @returns - code + */ + private async requestCliCode(redirectUri: string): Promise { + this.user.log.trace("LinkedInAuth", "requestCode"); + const state = String(Math.random()).substring(2); + const requestUrl = this.getRequestUrl(redirectUri, state); + const result = await OAuth2Service.requestRemotePermissions( + "LinkedIn", + requestUrl, + this.user.data.get("app", "OAUTH_HOSTNAME"), + Number(this.user.data.get("app", "OAUTH_PORT")), + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; + } + + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @param redirectUri + * @returns - TokenResponse + */ + private async exchangeCode( + code: string, + redirectUri: string, + ): Promise { + this.user.log.trace("LinkedInAuth", "exchangeCode"); + const tokens = (await this.post("accessToken", { + grant_type: "authorization_code", + code: code, + client_id: this.user.data.get("app", "LINKEDIN_CLIENT_ID"), + client_secret: this.user.data.get("app", "LINKEDIN_CLIENT_SECRET"), + redirect_uri: redirectUri, + })) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error("Invalid TokenResponse", tokens); + } + + return tokens; + } + + /** + * Refresh LinkedIn tokens + */ + async refresh() { + const tokens = (await this.post("accessToken", { + grant_type: "refresh_token", + refresh_token: this.user.data.get("auth", "LINKEDIN_REFRESH_TOKEN"), + client_id: this.user.data.get("app", "LINKEDIN_CLIENT_ID"), + client_secret: this.user.data.get("app", "LINKEDIN_CLIENT_SECRET"), + })) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "LinkedInAuth.refresh: response is not a TokenResponse", + tokens, + ); + } + await this.store(tokens); + } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + private async store(tokens: TokenResponse) { + this.user.data.set("auth", "LINKEDIN_ACCESS_TOKEN", tokens["access_token"]); + const accessExpiry = new Date( + new Date().getTime() + tokens["expires_in"] * 1000, + ).toISOString(); + this.user.data.set("auth", "LINKEDIN_ACCESS_EXPIRY", accessExpiry); + + this.user.data.set( + "auth", + "LINKEDIN_REFRESH_TOKEN", + tokens["refresh_token"], + ); + const refreshExpiry = new Date( + new Date().getTime() + tokens["refresh_token_expires_in"] * 1000, + ).toISOString(); + this.user.data.set("auth", "LINKEDIN_REFRESH_EXPIRY", refreshExpiry); + + this.user.data.set("auth", "LINKEDIN_SCOPE", tokens["scope"]); + await this.user.data.save(); + } + + // API implementation ------------------- + + /** + * Do a url-encoded POST request on the api. + * @param endpoint - the path to call + * @param body - body as object + */ + + private async post( + endpoint: string, + body: { [key: string]: string }, + ): Promise { + const url = new URL("https://www.linkedin.com"); + url.pathname = "oauth/" + this.API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body), + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleLinkedInError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + public async handleLinkedInError(error: ApiResponseError): Promise { + // it appears the linkedin oauth error + // is standard - http code 4xx, carrying a message + throw error; + } +} + +interface TokenResponse { + access_token: string; + token_type: "bearer"; + expires_in: number; + scope: string; + refresh_token: string; + refresh_token_expires_in: number; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("access_token" in tokens); + assert("expires_in" in tokens); + assert("scope" in tokens); + assert("refresh_token" in tokens); + assert("refresh_token_expires_in" in tokens); + } catch { + return false; + } + return true; +} diff --git a/src/platforms/Reddit/Reddit.ts b/src/platforms/Reddit/Reddit.ts new file mode 100644 index 0000000..e00d007 --- /dev/null +++ b/src/platforms/Reddit/Reddit.ts @@ -0,0 +1,424 @@ +import { basename } from "path"; +import { FileGroup, FieldMapping } from "../../types/index.ts"; + +import Source from "../../models/Source.ts"; + +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import RedditApi from "./RedditApi.ts"; +import RedditAuth from "./RedditAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; +import { XMLParser } from "fast-xml-parser"; + +/** + * Reddit: support for reddit platform + */ +export default class Reddit extends Platform { + assetsFolder = "_reddit"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + prefer: ["video"], + total_max: 1, + }, + imagesize: { + max_width: 3000, + }, + }; + settings: FieldMapping = { + REDDIT_SUBREDDIT: { + type: "string", + label: "Subreddit to post in", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + REDDIT_PLUGIN_SETTINGS: { + type: "json", + label: "Reddit Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + SUBREDDIT: string; + api: RedditApi; + auth: RedditAuth; + + constructor(user: User) { + super(user); + this.SUBREDDIT = this.user.data.get("settings", "REDDIT_SUBREDDIT", ""); + this.api = new RedditApi(user); + this.auth = new RedditAuth(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + return this.auth.connectApi(payload); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + const me = (await this.api.get("me")) as { + id: string; + name: string; + }; + if (!me) return false; + return { + id: me["id"], + name: me["name"], + }; + } + + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("Reddit.preparePost", source.id); + const post = await super.preparePost(source); + if (post) { + // TODO: extract video thumbnail + let videoposter = ""; + if (post.hasFiles(FileGroup.VIDEO)) { + let srcposter = ""; + const dstposter = this.assetsFolder + "/reddit-poster.png"; + const posters = post + .getFiles(FileGroup.IMAGE) + .filter((file) => file.basename === "poster"); + if (posters.length) { + srcposter = posters[0].name; + } else if (post.hasFiles(FileGroup.IMAGE)) { + // copy the first image to poster + srcposter = post.getFiles(FileGroup.IMAGE)[0].name; + } else { + // create a poster using ffmpeg + try { + throw this.user.log.error( + "video poster.jpg missing - thumbnails not implemented", + ); + // https://creatomate.com/blog/how-to-use-ffmpeg-in-nodejs + // const video = post.getFiles('video')[0]; + // this.user.log.trace("Reddit.preparePost", "creating thumbnail", video.name, dstposter); + // this.generateThumbnail(post.getFilePath(video.name),post.getFilePath(dstposter)); + } catch { + post.valid = false; + } + } + if (srcposter) { + // copy that file to its dest + this.user.log.trace( + "Reddit.preparePost", + "copying poster", + srcposter, + dstposter, + ); + await this.user.files.copy( + post.getFilePath(srcposter), + post.getFilePath(dstposter), + ); + post.removeFiles(FileGroup.IMAGE); + videoposter = dstposter; + } + } + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "REDDIT_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + if (videoposter) { + await post.addFile(videoposter); + } + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Reddit.publishPost", post.id, dryrun); + + let response = {}; + let error = undefined as Error | undefined; + + if (post.hasFiles(FileGroup.VIDEO)) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else if (post.hasFiles(FileGroup.IMAGE)) { + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + return post.processResult( + "#unknown", // todo: listen to websocket for id + "#unknown", // todo: listen to websocket for link + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); + } + + /** + * POST self-post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishTextPost(post: Post, dryrun = false): Promise { + this.user.log.trace("Reddit.publishTextPost"); + const title = post.title; + const body = post.getCompiledBody("!title"); + if (!dryrun) { + const response = (await this.api.post("submit", { + sr: this.SUBREDDIT, + kind: "self", + title: title, + text: body, + api_type: "json", + extension: "json", + })) as { + json: { + errors: string[][]; + data: { + user_submitted_page: string; + websocket_url: string; + }; + }; + }; + if (response.json?.errors?.length) { + throw this.user.log.error(response.json.errors.flat()); + } + return response; + } + return { + dryrun: true, + }; + } + + /** + * POST image post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishImagePost(post: Post, dryrun = false): Promise { + this.user.log.trace("Reddit.publishImagePost"); + const title = post.title; + const image = post.getFiles(FileGroup.IMAGE)[0]; + const file = post.getFilePath(image.name); + const leash = await this.getUploadLeash(file, image.mimetype); + const imageUrl = await this.uploadFile(leash, file); + if (!dryrun) { + const response = (await this.api.post("submit", { + sr: this.SUBREDDIT, + kind: "image", + title: title, + url: imageUrl, + api_type: "json", + extension: "json", + })) as { + json: { + errors: string[]; + data: { + user_submitted_page: string; + websocket_url: string; + }; + }; + }; + if (response.json?.errors?.length) { + throw this.user.log.error(response.json.errors.flat()); + } + return response; + } + return { + dryrun: true, + }; + } + + /** + * POST video post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishVideoPost(post: Post, dryrun = false): Promise { + this.user.log.trace("Reddit.publishVideoPost"); + const title = post.title; + + // upload poster first + const poster = post.getFiles(FileGroup.IMAGE)[0]; + const posterFile = post.getFilePath(poster.name); + const posterLeash = await this.getUploadLeash(posterFile, poster.mimetype); + const posterUrl = await this.uploadFile(posterLeash, posterFile); + + // upload video with poster + const video = post.getFiles(FileGroup.VIDEO)[0]; + const file = post.getFilePath(video.name); + const leash = await this.getUploadLeash(file, video.mimetype); + const videoUrl = await this.uploadFile(leash, file); + if (!dryrun) { + const response = (await this.api.post("submit", { + sr: this.SUBREDDIT, + kind: "video", + title: title, + url: videoUrl, + video_poster_url: posterUrl, + api_type: "json", + extension: "json", + })) as { + json: { + errors: string[]; + data: { + user_submitted_page: string; + websocket_url: string; + }; + }; + }; + if (response.json?.errors?.length) { + throw this.user.log.error(response.json.errors.flat()); + } + return response; + } + return { + dryrun: true, + }; + } + + /** + * POST to media/asset.json to get a leash with a lot of fields, + * + * All these fields should be reposted on the upload + * @param file - path to the file to upload + * @param mimetype + * @returns leash - args with action and fields + */ + private async getUploadLeash( + file: string, + mimetype: string, + ): Promise<{ + action: string; + fields: { + [name: string]: string; + }; + }> { + const filename = basename(file); + + const form = new FormData(); + form.append("filepath", filename); + form.append("mimetype", mimetype); + + const leash = (await this.api.postForm("media/asset.json", form)) as { + args: { + action: string; + fields: { + name: string; + value: string; + }[]; + }; + }; + if (!leash.args?.action || !leash.args?.fields) { + const msg = "Reddit.getUploadLeash: bad answer"; + throw this.user.log.error(msg, leash); + } + + return { + action: "https:" + leash.args.action, + fields: Object.assign( + {}, + ...leash.args.fields.map((f) => ({ [f.name]: f.value })), + ), + }; + } + + /** + * POST file as formdata using a leash + * @param leash + * @param leash.action - url to post to + * @param leash.fields - fields to post + * @param file - path to the file to upload + * @returns url to uploaded file + */ + private async uploadFile( + leash: { + action: string; + fields: { + [name: string]: string; + }; + }, + file: string, + ): Promise { + const buffer = await this.user.files.readBuffer(file); + const blob = new Blob([buffer]); + const filename = basename(file); + + const form = new FormData(); + for (const fieldname in leash.fields) { + form.append(fieldname, leash.fields[fieldname]); + } + form.append("file", blob, filename); + this.user.log.trace("POST", leash.action); + + const responseRaw = await fetch(leash.action, { + method: "POST", + headers: { + Accept: "application/json", + }, + body: form, + }); + const response = await responseRaw.text(); + try { + const parser = new XMLParser(); + const xml = parser.parse(response); + const encodedURL = xml.PostResponse.Location; + if (!encodedURL) { + const msg = "Reddit.uploadFile: No URL returned"; + throw this.user.log.error(msg, xml); + } + return decodeURIComponent(encodedURL); + } catch (e) { + const msg = "Reddit.uploadFile: cant parse xml"; + throw this.user.log.error(msg, response, e); + } + } +} diff --git a/src/platforms/Reddit/RedditApi.ts b/src/platforms/Reddit/RedditApi.ts new file mode 100644 index 0000000..da16b26 --- /dev/null +++ b/src/platforms/Reddit/RedditApi.ts @@ -0,0 +1,135 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import User from "../../models/User.ts"; + +/** + * RedditApi: support for reddit platform. + */ + +export default class RedditApi { + API_VERSION = "v1"; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Do a GET request on the api. + * @param endpoint - the path to call + * @param query - query string as object + */ + + public async get( + endpoint: string, + query: { [key: string]: string } = {}, + ): Promise { + const url = new URL("https://oauth.reddit.com"); + url.pathname = "api/" + this.API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + + const accessToken = this.user.data.get("auth", "REDDIT_ACCESS_TOKEN"); + + this.user.log.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: "Bearer " + accessToken, + "User-Agent": this.user.data.get("app", "OAUTH_USERAGENT"), + }, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a url-encoded POST request on the api. + * @param endpoint - the path to call + * @param body - body as object + */ + + public async post( + endpoint: string, + body: { [key: string]: string }, + ): Promise { + const url = new URL("https://oauth.reddit.com"); + //url.pathname = "api/" + this.API_VERSION + "/" + endpoint; + url.pathname = "api/" + endpoint; + + const accessToken = this.user.data.get("auth", "REDDIT_ACCESS_TOKEN"); + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: "Bearer " + accessToken, + "User-Agent": this.user.data.get("app", "OAUTH_USERAGENT"), + }, + body: new URLSearchParams(body), + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Do a FormData POST request on the api. + * @param endpoint - the path to call + * @param body - body as object + */ + + public async postForm(endpoint: string, body: FormData): Promise { + const url = new URL("https://oauth.reddit.com"); + //url.pathname = "api/" + this.API_VERSION + "/" + endpoint; + url.pathname = "api/" + endpoint; + + const accessToken = this.user.data.get("auth", "REDDIT_ACCESS_TOKEN"); + this.user.log.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: "Bearer " + accessToken, + "User-Agent": this.user.data.get("app", "OAUTH_USERAGENT"), + }, + body: body, + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + private async handleRedditError(error: ApiResponseError): Promise { + if (error.responseData) { + if (error.responseData.json?.errors?.length) { + error.message += + ":" + + error.responseData.json.errors[0] + + "-" + + error.responseData.json.errors.slice(1).join(); + } + } else { + if (error instanceof SyntaxError) { + // response.json() Unexpected token < in JSON + error.message += "- perhaps refresh your tokens"; + } + } + throw error; + } +} diff --git a/src/platforms/Reddit/RedditAuth.ts b/src/platforms/Reddit/RedditAuth.ts new file mode 100644 index 0000000..768716c --- /dev/null +++ b/src/platforms/Reddit/RedditAuth.ts @@ -0,0 +1,210 @@ +import { + ApiResponseError, + handleApiError, + handleJsonResponse, +} from "../../utilities.ts"; + +import OAuth2Service from "../../services/OAuth2Service.ts"; +import User from "../../models/User.ts"; +import { strict as assert } from "assert"; + +export default class RedditAuth { + API_VERSION = "v1"; + + user: User; + + constructor(user: User) { + this.user = user; + } + async connectCli() { + const code = await this.requestCode(); + const tokens = await this.exchangeCode(code); + await this.store(tokens); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async connectApi(payload: object) { + throw this.user.log.error("RedditAuth:connectApi - not implemented"); + } + + /** + * Refresh Reddit Access token + * + * Reddits access token expire in 24 hours. + * Refresh this regularly. + */ + public async refresh() { + const tokens = (await this.post("access_token", { + grant_type: "refresh_token", + refresh_token: this.user.data.get("auth", "REDDIT_REFRESH_TOKEN"), + })) as TokenResponse; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "RedditAuth.refresh: response is not a TokenResponse", + tokens, + ); + } + await this.store(tokens); + } + + /** + * Request remote code using OAuth2Service + * @returns - code + */ + protected async requestCode(): Promise { + this.user.log.trace("RedditAuth", "requestCode"); + const clientId = this.user.data.get("app", "REDDIT_CLIENT_ID"); + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const state = String(Math.random()).substring(2); + + // create auth url + const url = new URL("https://www.reddit.com"); + url.pathname = "api/" + this.API_VERSION + "/authorize"; + const query = { + client_id: clientId, + redirect_uri: OAuth2Service.getCallbackUrl(clientHost, clientPort), + state: state, + response_type: "code", + duration: "permanent", + scope: ["identity", "submit"].join(), + }; + url.search = new URLSearchParams(query).toString(); + + const result = await OAuth2Service.requestRemotePermissions( + "Reddit", + url.href, + clientHost, + clientPort, + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; + } + + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @returns - TokenResponse + */ + protected async exchangeCode(code: string): Promise { + this.user.log.trace("RedditAuth", "exchangeCode", code); + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const redirectUri = OAuth2Service.getCallbackUrl(clientHost, clientPort); + + const tokens = (await this.post("access_token", { + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + })) as { + access_token: string; + token_type: "bearer"; + expires_in: number; + scope: string; + refresh_token: string; + }; + + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "RedditAuth.exchangeCode: response is not a TokenResponse", + tokens, + ); + } + + return tokens; + } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + private async store(tokens: TokenResponse) { + this.user.data.set("auth", "REDDIT_ACCESS_TOKEN", tokens["access_token"]); + const accessExpiry = new Date( + new Date().getTime() + tokens["expires_in"] * 1000, + ).toISOString(); + this.user.data.set("auth", "REDDIT_ACCESS_EXPIRY", accessExpiry); + this.user.data.set("auth", "REDDIT_REFRESH_TOKEN", tokens["refresh_token"]); + this.user.data.set("auth", "REDDIT_SCOPE", tokens["scope"]); + await this.user.data.save(); + } + + // API implementation ------------------- + + /** + * Do a url-encoded POST request on the api. + * @param endpoint - the path to call + * @param body - body as object + */ + + private async post( + endpoint: string, + body: { [key: string]: string }, + ): Promise { + const url = new URL("https://www.reddit.com"); + url.pathname = "api/" + this.API_VERSION + "/" + endpoint; + this.user.log.trace("POST", url.href); + + const clientId = this.user.data.get("app", "REDDIT_CLIENT_ID"); + const clientSecret = this.user.data.get("app", "REDDIT_CLIENT_SECRET"); + const userpass = clientId + ":" + clientSecret; + const userpassb64 = Buffer.from(userpass).toString("base64"); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + Authorization: "Basic " + userpassb64, + }, + body: new URLSearchParams(body), + }) + .then((res) => handleJsonResponse(res)) + .catch((err) => this.handleRedditError(err)) + .catch((err) => handleApiError(err, this.user)); + } + + /** + * Handle api error + * + * Improve error message and rethrow it. + * @param error - ApiResponseError + */ + private async handleRedditError(error: ApiResponseError): Promise { + // it appears the reddit oauth error + // is standard - http code 4xx, carrying a message + throw error; + } +} + +interface TokenResponse { + access_token: string; + token_type: "bearer"; + expires_in: number; + scope: string; + refresh_token: string; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("access_token" in tokens); + assert("expires_in" in tokens); + assert("scope" in tokens); + assert("refresh_token" in tokens); + } catch { + return false; + } + return true; +} diff --git a/src/platforms/Twitter/Twitter.ts b/src/platforms/Twitter/Twitter.ts new file mode 100644 index 0000000..51586ae --- /dev/null +++ b/src/platforms/Twitter/Twitter.ts @@ -0,0 +1,328 @@ +import { FileGroup, FieldMapping } from "../../types/index.ts"; +import Source from "../../models/Source.ts"; + +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import { TwitterApi } from "twitter-api-v2"; +import TwitterAuth from "./TwitterAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; + +/** + * Twitter: support for twitter platform + */ + +enum EUploadMimeType { + Jpeg = "image/jpeg", + Mp4 = "video/mp4", + Mov = "video/quicktime", + Gif = "image/gif", + Png = "image/png", + Srt = "text/plain", + Webp = "image/webp", +} + +export default class Twitter extends Platform { + assetsFolder = "_twitter"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + video_max: 0, + image_max: 4, + }, + imagesize: { + max_width: 5000, + max_size: 5000, + }, + }; + settings: FieldMapping = { + TWITTER_OA1_ADDITIONAL_OWNER: { + type: "string", + label: "Twitter additional owner (for OAuth1 file uploads)", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + TWITTER_PLUGIN_SETTINGS: { + type: "json", + label: "Twitter Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + auth: TwitterAuth; + + constructor(user: User) { + super(user); + this.auth = new TwitterAuth(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + return this.auth.connectApi(payload); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + this.user.log.trace("Twitter.test: get oauth1 api"); + const client1 = new TwitterApi({ + appKey: this.user.data.get("app", "TWITTER_OA1_API_KEY"), + appSecret: this.user.data.get("app", "TWITTER_OA1_API_KEY_SECRET"), + accessToken: this.user.data.get("app", "TWITTER_OA1_ACCESS_TOKEN"), + accessSecret: this.user.data.get("app", "TWITTER_OA1_ACCESS_SECRET"), + }); + const creds1 = await client1.v1.verifyCredentials(); + this.user.log.trace("Twitter.test: get oauth2 api"); + const client2 = new TwitterApi( + this.user.data.get("auth", "TWITTER_ACCESS_TOKEN"), + ); + const creds2 = await client2.v2.me(); + return { + oauth1: { + id: creds1["id"], + name: creds1["name"], + screen_name: creds1["screen_name"], + url: creds1["url"], + }, + oauth2: creds2["data"], + }; + } + + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("Twitter.preparePost", source.id); + const post = await super.preparePost(source); + if (post) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "TWITTER_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + + // remove files whose mime are not supported, + // this could be a plugin + for (const file of post.getFiles()) { + if ( + !Object.values(EUploadMimeType).includes( + file.mimetype as EUploadMimeType, + ) + ) { + this.user.log.trace( + "Removing unsupported file type: " + file.mimetype, + ); + post.removeFile(file.name); + } + } + + // limit the post body to 140 characters + // this could be a plugin + const charLimit = 140; + if (post.body && post.body.length >= charLimit) { + const splitBody = post.body.match(/[^.\n]+[.\n]*|[.\n]+/g); + if (splitBody) { + let newBody = ""; + let nextLine = splitBody.shift(); + while (nextLine && newBody.length + nextLine.length < charLimit) { + newBody += nextLine; + nextLine = splitBody.shift(); + } + if (newBody !== "") { + post.body = newBody; + } + } + if (post.body.length >= charLimit) { + post.body = post.body.substring(0, charLimit - 4) + "..."; + } + } + + // twitter requires a real body or images + if (!post.body && !post.hasFiles(FileGroup.IMAGE)) { + this.user.log.warn("Twitter post has no body"); + post.valid = false; + } + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("Twitter.publishPost", post.id, dryrun); + + let response = { data: { id: "-99" } } as { + data: { + id: string; + }; + }; + let error = undefined as Error | undefined; + + if (post.hasFiles(FileGroup.IMAGE)) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e as Error; + } + } + + return post.processResult( + response.data.id, + "https://twitter.com/user/status/" + response.data.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); + } + + /** + * tweet body using oauth2 client + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post + */ + private async publishTextPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + data: { + id: string; + }; + }> { + this.user.log.trace("Twitter.publishTextPost", post.id, dryrun); + if (!dryrun) { + const client2 = new TwitterApi( + this.user.data.get("auth", "TWITTER_ACCESS_TOKEN"), + ); + const result = await client2.v2.tweet({ + text: post.getCompiledBody(), + }); + if (result.errors) { + throw this.user.log.error(result.errors.join()); + } + return result; + } + return { + data: { + id: "-99", + }, + }; + } + + /** + * Upload a images to twitter using oauth1 client + * and create a post with body & media using oauth2 client + * @param post - the post to publish + * @param dryrun - wether to actually post it + * @returns object incl id of the created post + */ + private async publishImagesPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + data: { + id: string; + }; + }> { + this.user.log.trace("Twitter.publishImagesPost", post.id, dryrun); + + const client1 = new TwitterApi({ + appKey: this.user.data.get("app", "TWITTER_OA1_API_KEY"), + appSecret: this.user.data.get("app", "TWITTER_OA1_API_KEY_SECRET"), + accessToken: this.user.data.get("app", "TWITTER_OA1_ACCESS_TOKEN"), + accessSecret: this.user.data.get("app", "TWITTER_OA1_ACCESS_SECRET"), + }); + // eslint-disable-next-line + const mediaIds = new Array() as + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string]; + + const additionalOwner = this.user.data.get( + "settings", + "TWITTER_OA1_ADDITIONAL_OWNER", + "", + ); + for (const image of post.getFiles(FileGroup.IMAGE).splice(0, 4)) { + const path = post.getFilePath(image.name); + const buffer = await post.platform.user.files.readBuffer(path); + this.user.log.trace("Uploading " + path + "..."); + try { + mediaIds.push( + await client1.v1.uploadMedia(buffer, { + mimeType: image.mimetype, + // mimeType : '' //MIME type as a string. To help you across allowed MIME types, enum EUploadMimeType is here for you. This option is required if file is not specified as string. + // target: 'tweet' //Target type tweet or dm. Defaults to tweet. You must specify it if you send a media to use in DMs. + // longVideo : false //Specify true here if you're sending a video and it can exceed 120 seconds. Otherwise, this option has no effet. + // shared: false //Specify true here if you want to use this media in Welcome Direct Messages. + ...(additionalOwner && { additionalOwners: [additionalOwner] }), + // maxConcurrentUploads: 3 //Number of concurrent chunk uploads allowed to be sent. Defaults to 3. + }), + ); + } catch (e) { + throw this.user.log.error("Twitter.publishPost uploadMedia failed", e); + } + } + + const client2 = new TwitterApi( + this.user.data.get("auth", "TWITTER_ACCESS_TOKEN"), + ); + + if (!dryrun) { + this.user.log.trace("Tweeting " + post.id + "..."); + const result = await client2.v2.tweet({ + text: post.getCompiledBody(), + media: { + media_ids: mediaIds, + }, + }); + if (result.errors) { + throw this.user.log.error(result.errors.join()); + } + return result; + } + + return { + data: { + id: "-99", + }, + }; + } +} diff --git a/src/platforms/Twitter/TwitterAuth.ts b/src/platforms/Twitter/TwitterAuth.ts new file mode 100644 index 0000000..76128dc --- /dev/null +++ b/src/platforms/Twitter/TwitterAuth.ts @@ -0,0 +1,156 @@ +import OAuth2Service from "../../services/OAuth2Service.ts"; +import { TwitterApi } from "twitter-api-v2"; +import User from "../../models/User.ts"; +import { strict as assert } from "assert"; + +export default class TwitterAuth { + client?: TwitterApi; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Set up Twitter platform + */ + async connectCli() { + const { code, verifier } = await this.requestCode(); + const tokens = await this.exchangeCode(code, verifier); + await this.store(tokens); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async connectApi(payload: object) { + throw this.user.log.error("TwitterAuth:connectApi - not implemented"); + } + + /** + * Refresh Twitter tokens + */ + async refresh() { + const tokens = (await this.getClient().refreshOAuth2Token( + this.user.data.get("auth", "TWITTER_REFRESH_TOKEN"), + )) as TokenResponse; + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "TwitterAuth.refresh: response is not a TokenResponse", + tokens, + ); + } + await this.store(tokens); + } + + /** + * Get or create a TwitterApi client + * @returns - TwitterApi + */ + private getClient(): TwitterApi { + if (this.client) { + return this.client; + } + this.client = new TwitterApi({ + clientId: this.user.data.get("app", "TWITTER_CLIENT_ID"), + clientSecret: this.user.data.get("app", "TWITTER_CLIENT_SECRET"), + }); + return this.client; + } + + /** + * Request remote code using OAuth2Service + * @returns - {code, verifier} + */ + private async requestCode(): Promise<{ code: string; verifier: string }> { + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const { url, codeVerifier, state } = + this.getClient().generateOAuth2AuthLink( + OAuth2Service.getCallbackUrl(clientHost, clientPort), + { + scope: ["users.read", "tweet.read", "tweet.write", "offline.access"], + }, + ); + const result = await OAuth2Service.requestRemotePermissions( + "Twitter", + url, + clientHost, + clientPort, + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error("TwitterApi.requestCode: " + msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error("TwitterApi.requestCode: " + msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error("TwitterApi.requestCode: " + msg, result); + } + return { + code: result["code"] as string, + verifier: codeVerifier, + }; + } + + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @param verifier - the code verifier to use + * @returns - TokenResponse + */ + private async exchangeCode( + code: string, + verifier: string, + ): Promise { + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const tokens = (await this.getClient().loginWithOAuth2({ + code: code, + codeVerifier: verifier, + redirectUri: OAuth2Service.getCallbackUrl(clientHost, clientPort), + })) as TokenResponse; + if (!isTokenResponse(tokens)) { + throw this.user.log.error( + "TitterAuth.requestAccessToken: reponse is not a valid TokenResponse", + ); + } + + return tokens; + } + + /** + * Save all tokens in auth store + * @param tokens - the tokens to store + */ + private async store(tokens: TokenResponse) { + this.user.data.set("auth", "TWITTER_ACCESS_TOKEN", tokens["accessToken"]); + const accessExpiry = new Date( + new Date().getTime() + tokens["expiresIn"] * 1000, + ).toISOString(); + this.user.data.set("auth", "TWITTER_ACCESS_EXPIRY", accessExpiry); + + this.user.data.set("auth", "TWITTER_REFRESH_TOKEN", tokens["refreshToken"]); + await this.user.data.save(); + } +} + +interface TokenResponse { + client: TwitterApi; + accessToken: string; + expiresIn: number; + refreshToken: string; +} + +function isTokenResponse(tokens: TokenResponse) { + try { + assert("accessToken" in tokens); + assert("expiresIn" in tokens); + assert("refreshToken" in tokens); + } catch { + return false; + } + return true; +} diff --git a/src/platforms/YouTube/YouTube.ts b/src/platforms/YouTube/YouTube.ts new file mode 100644 index 0000000..8b037b2 --- /dev/null +++ b/src/platforms/YouTube/YouTube.ts @@ -0,0 +1,260 @@ +import { FileGroup, FieldMapping } from "../../types/index.ts"; +import Source from "../../models/Source.ts"; + +import Platform from "../../models/Platform.ts"; +import Post from "../../models/Post.ts"; +import User from "../../models/User.ts"; +import Operator from "../../models/Operator.ts"; +import YouTubeAuth from "./YouTubeAuth.ts"; +import PlatformMapper from "../../mappers/PlatformMapper.ts"; + +export default class YouTube extends Platform { + assetsFolder = "_youtube"; + postFileName = "post.json"; + pluginSettings = { + limitfiles: { + exclusive: ["video"], + video_min: 1, + video_max: 1, + }, + }; + settings: FieldMapping = { + YOUTUBE_CATEGORY: { + type: "string", + label: "Youtube category", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: true, + }, + YOUTUBE_PRIVACY: { + type: "string", + label: "Youtube privacy", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, // ? + // default: public + }, + YOUTUBE_PLUGIN_SETTINGS: { + type: "json", + label: "Youtube Plugin settings", + get: ["managePlatforms"], + set: ["managePlatforms"], + required: false, + default: this.pluginSettings, + }, + }; + auth: YouTubeAuth; + + // post defaults + notifySubscribers = true; + onBehalfOfContentOwner = ""; + onBehalfOfContentOwnerChannel = ""; + defaultLanguage = "en-us"; + embeddable = true; + license = "youtube"; + publicStatsViewable = true; + selfDeclaredMadeForKids = false; + + constructor(user: User) { + super(user); + this.auth = new YouTubeAuth(user); + this.mapper = new PlatformMapper(this); + } + + /** @inheritdoc */ + async connect(operator: Operator, payload?: object) { + if (operator.ui === "cli") { + await this.auth.connectCli(); + return await this.test(); + } + if (operator.ui === "api") { + if (!payload) { + throw this.user.log.error("Connect via api requires a payload"); + } + return this.auth.connectApi(payload); + } + throw this.user.log.error( + `${this.id} connect: ui ${operator.ui} not supported`, + ); + } + + /** @inheritdoc */ + async test() { + return this.getChannel(); + } + + /** @inheritdoc */ + async refresh(): Promise { + await this.auth.refresh(); + return true; + } + + /** @inheritdoc */ + async preparePost(source: Source): Promise { + this.user.log.trace("YouTube.preparePost", source.id); + const post = await super.preparePost(source); + if (post) { + const userPluginSettings = JSON.parse( + this.user.data.get("settings", "YOUTUBE_PLUGIN_SETTINGS", "{}"), + ); + const pluginSettings = { + ...this.pluginSettings, + ...(userPluginSettings || {}), + }; + const plugins = this.loadPlugins(pluginSettings); + for (const plugin of plugins) { + await plugin.process(post); + } + await post.save(); + } + return post; + } + + /** @inheritdoc */ + async publishPost(post: Post, dryrun: boolean = false): Promise { + this.user.log.trace("YouTube.publishPost", post.id, dryrun); + + let response = { id: "-99" } as { + id?: string; + }; + let error = undefined as Error | undefined; + + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e as Error; + } + + return post.processResult( + response.id as string, + "https://www.youtube.com/watch?v=" + response.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); + } + + // Platform API Specific + + /** + * GET part of the channel snippet + * @returns object, incl. some ids and names + */ + private async getChannel() { + this.user.log.trace("YouTube", "getChannel"); + const client = this.auth.getClient(); + const result = (await client.channels.list({ + part: ["snippet", "contentDetails", "status"], + mine: true, + })) as { + data?: { + items?: { + id: string; + snippet: { + title: string; + customUrl: string; + }; + }[]; + }; + status: number; + statusText: string; + }; + if (result.data?.items?.length) { + return { + id: result.data.items[0].id, + snippet: { + title: result.data.items[0].snippet.title, + customUrl: result.data.items[0].snippet.customUrl, + }, + }; + } + throw this.user.log.error("YouTube.getChannel", "invalid result", result); + } + + /** + * POST title & body & video to the posts endpoint using json + * + * untested. + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishVideoPost(post: Post, dryrun: boolean = false) { + this.user.log.trace("YouTube.publishVideoPost", dryrun); + + const file = post.getFiles(FileGroup.VIDEO)[0]; + + const client = this.auth.getClient(); + this.user.log.trace( + "YouTube.publishVideoPost", + "uploading " + file.name + " ...", + ); + + // is this indeed a stream ? + // https://github.com/duna-oss/flystorage/issues/108 + const stream = await this.user.files.read(post.getFilePath(file.name)); + const result = (await client.videos.insert({ + part: ["snippet", "status"], + notifySubscribers: this.notifySubscribers, + ...(this.onBehalfOfContentOwner && { + onBehalfOfContentOwner: this.onBehalfOfContentOwner, + }), + ...(this.onBehalfOfContentOwnerChannel && { + onBehalfOfContentOwnerChannel: this.onBehalfOfContentOwnerChannel, + }), + requestBody: { + snippet: { + title: post.title, + description: post.getCompiledBody("!title"), + tags: post.tags, // both in body and separate + categoryId: this.user.data.get("settings", "YOUTUBE_CATEGORY", ""), + defaultLanguage: this.defaultLanguage, + }, + status: { + embeddable: this.embeddable, + license: this.license, + publicStatsViewable: this.publicStatsViewable, + selfDeclaredMadeForKids: this.selfDeclaredMadeForKids, + privacyStatus: this.user.data.get("settings", "YOUTUBE_PRIVACY"), + }, + }, + media: { + mimeType: file.mimetype, + body: stream, + }, + })) as { + data: { + id: string; + status?: { + uploadStatus: string; + failureReason: string; + rejectionReason: string; + }; + snippet: object; + }; + }; + + if (result.data.status?.uploadStatus !== "uploaded") { + throw this.user.log.error( + "YouTube.publishVideoPost", + "failed", + result.data.status?.uploadStatus, + result.data.status?.failureReason, + result.data.status?.rejectionReason, + ); + } + if (!result.data.id) { + throw this.user.log.error( + "YouTube.publishVideoPost", + "missing id in result", + result, + ); + } + + return { id: result.data.id }; + } +} diff --git a/src/platforms/YouTube/YouTubeAuth.ts b/src/platforms/YouTube/YouTubeAuth.ts new file mode 100644 index 0000000..648e814 --- /dev/null +++ b/src/platforms/YouTube/YouTubeAuth.ts @@ -0,0 +1,186 @@ +import { Credentials, OAuth2Client } from "google-auth-library"; + +import OAuth2Service from "../../services/OAuth2Service.ts"; +import User from "../../models/User.ts"; +import { strict as assert } from "assert"; +import { youtube_v3 } from "@googleapis/youtube"; + +export default class YouTubeAuth { + client?: youtube_v3.Youtube; + + user: User; + + constructor(user: User) { + this.user = user; + } + + /** + * Set up YouTube platform + */ + async connectCli() { + const code = await this.requestCode(); + const tokens = await this.exchangeCode(code); + await this.store(tokens); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async connectApi(payload: object) { + throw this.user.log.error("YouTubeAuth:connectApi - not implemented"); + } + + /** + * Refresh YouTube tokens + */ + async refresh() { + this.user.log.trace("YouTubeAuth", "refresh"); + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + ); + auth.setCredentials({ + access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), + refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), + }); + const response = (await auth.refreshAccessToken()) as { + res?: { data: Credentials }; + credentials?: Credentials; + }; + if (response["res"]?.["data"] && isCredentials(response["res"]["data"])) { + await this.store(response["res"]["data"]); + return; + } else if (response.credentials) { + await this.store(response.credentials); + return; + } + throw this.user.log.error( + "YouTubeAuth.refresh", + "not a valid response", + response, + ); + } + + /** + * Get or create a YouTube client + * @returns - youtube_v3.Youtube + */ + public getClient(): youtube_v3.Youtube { + if (this.client) { + return this.client; + } + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + ); + auth.setCredentials({ + access_token: this.user.data.get("auth", "YOUTUBE_ACCESS_TOKEN"), + refresh_token: this.user.data.get("auth", "YOUTUBE_REFRESH_TOKEN"), + }); + auth.on("tokens", async (creds) => { + this.user.log.trace("YouTubeAuth", "tokens event received"); + await this.store(creds); + }); + this.client = new youtube_v3.Youtube({ auth }); + return this.client; + } + + /** + * Request remote code using OAuth2Service + * @returns - code + */ + private async requestCode(): Promise { + this.user.log.trace("YouTubeAuth", "requestCode"); + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + const state = String(Math.random()).substring(2); + + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + OAuth2Service.getCallbackUrl(clientHost, clientPort), + ); + const url = auth.generateAuthUrl({ + access_type: "offline", + scope: [ + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + ], + state: state, + }); + + const result = await OAuth2Service.requestRemotePermissions( + "YouTube", + url, + clientHost, + clientPort, + ); + if (result["error"]) { + const msg = result["error_reason"] + " - " + result["error_description"]; + throw this.user.log.error(msg, result); + } + if (result["state"] !== state) { + const msg = "Response state does not match request state"; + throw this.user.log.error(msg, result); + } + if (!result["code"]) { + const msg = "Remote response did not return a code"; + throw this.user.log.error(msg, result); + } + return result["code"] as string; + } + + /** + * Exchange remote code for tokens + * @param code - the code to exchange + * @returns - Credentials + */ + private async exchangeCode(code: string): Promise { + this.user.log.trace("YouTubeAuth", "exchangeCode", code); + + const clientHost = this.user.data.get("app", "OAUTH_HOSTNAME"); + const clientPort = Number(this.user.data.get("app", "OAUTH_PORT")); + + const auth = new OAuth2Client( + this.user.data.get("app", "YOUTUBE_CLIENT_ID"), + this.user.data.get("app", "YOUTUBE_CLIENT_SECRET"), + OAuth2Service.getCallbackUrl(clientHost, clientPort), + ); + + const response = await auth.getToken(code); + if (!isCredentials(response.tokens)) { + throw this.user.log.error("Invalid response for getToken", response); + } + return response.tokens; + } + + /** + * Save all tokens in auth store + * @param creds - contains the tokens to store + */ + private async store(creds: Credentials) { + this.user.log.trace("YouTubeAuth", "store"); + if (creds.access_token) { + this.user.data.set("auth", "YOUTUBE_ACCESS_TOKEN", creds.access_token); + } + if (creds.expiry_date) { + const accessExpiry = new Date(creds.expiry_date).toISOString(); + this.user.data.set("auth", "YOUTUBE_ACCESS_EXPIRY", accessExpiry); + } + if (creds.scope) { + this.user.data.set("auth", "YOUTUBE_SCOPE", creds.scope); + } + if (creds.refresh_token) { + this.user.data.set("auth", "YOUTUBE_REFRESH_TOKEN", creds.refresh_token); + } + await this.user.data.save(); + } +} + +function isCredentials(creds: Credentials) { + try { + assert("access_token" in creds || "refresh_token" in creds); + } catch { + return false; + } + return true; +} diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 72c22bd..5dc9285 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -1,18 +1,18 @@ -export { default as AsYouTube } from "./AsYouTube"; -export { default as AsInstagram } from "./AsInstagram"; -export { default as AsTwitter } from "./AsTwitter"; -export { default as AsFacebook } from "./AsFacebook"; -export { default as AsTikTok } from "./AsTikTok"; -export { default as AsLinkedIn } from "./AsLinkedIn"; -export { default as AsReddit } from "./AsReddit"; +export { default as Facebook } from "./Facebook/Facebook.ts"; +export { default as Instagram } from "./Instagram/Instagram.ts"; +export { default as Twitter } from "./Twitter/Twitter.ts"; +export { default as Reddit } from "./Reddit/Reddit.ts"; +export { default as LinkedIn } from "./LinkedIn/LinkedIn.ts"; +export { default as YouTube } from "./YouTube/YouTube.ts"; +export { default as Bluesky } from "./Bluesky/Bluesky.ts"; -export enum PlatformSlug { - UNKNOWN = "unknown", - ASYOUTUBE = "asyoutube", - ASINSTAGRAM = "asinstagram", - ASFACEBOOK = "asfacebook", - ASTWITTER = "astwitter", - ASTIKTOK = "astiktok", - ASLINKEDIN = "aslinkedin", - ASREDDIT = "asreddit" -} \ No newline at end of file +export enum PlatformId { + UNKNOWN = "unknown", + FACEBOOK = "facebook", + INSTAGRAM = "instagram", + TWITTER = "twitter", + REDDIT = "reddit", + LINKEDIN = "linkedin", + YOUTUBE = "youtube", + BLUEKSY = "bluesky", +} diff --git a/src/plugins/ImageFrame.ts b/src/plugins/ImageFrame.ts new file mode 100644 index 0000000..febb1a8 --- /dev/null +++ b/src/plugins/ImageFrame.ts @@ -0,0 +1,114 @@ +import { FileGroup, FileInfo } from "../types/index.ts"; + +import Plugin from "../models/Plugin.ts"; +import Post from "../models/Post.ts"; +import sharp from "sharp"; + +interface ImageFrameSettings { + inner_width: string | number; // set to 0 or "" to ignore + inner_color: string; // css color + outer_width: string | number; // set to 0 or "" to ignore + outer_color: string; // css color +} + +// https://sharp.pixelplumbing.com/api-resize#extend +/** + * Plugin ImageFrame. + * + * Add single or double border around images from Post + * + */ +export default class ImageFrame extends Plugin { + static defaults: ImageFrameSettings = { + inner_width: "1%", + inner_color: "black", + outer_width: "9%", + outer_color: "white", + }; + settings: ImageFrameSettings; + + constructor(settings?: object) { + super(); + this.settings = { + ...ImageFrame.defaults, + ...(settings ?? {}), + }; + } + + /** + * Process the post + */ + + async process(post: Post): Promise { + post.platform.user.log.trace(this.id, post.id, "process"); + for (const file of post.getFiles(FileGroup.IMAGE)) { + await this.addImageFrame(post, file); + } + } + + private async addImageFrame(post: Post, file: FileInfo) { + if (file.width && file.height) { + const size = Math.min(file.width, file.height); + const newFileName = file.basename + "-framed." + file.extension; + const src = file.name; + const dst = post.platform.assetsFolder + "/" + newFileName; + + const fileIn = post.getFilePath(src); + const fileOut = post.getFilePath(dst); + const bufferIn = await post.platform.user.files.readBuffer(fileIn); + const source = sharp(bufferIn); + + let innerBuffer = await source.toBuffer(); + if (this.settings.inner_width) { + let inner_width = 0; + if (typeof this.settings.inner_width === "string") { + if (this.settings.inner_width.endsWith("%")) { + inner_width = Math.round( + (size * parseInt(this.settings.inner_width)) / 100, + ); + } else { + inner_width = parseInt(this.settings.inner_width); + } + } else { + inner_width = this.settings.inner_width; + } + innerBuffer = await source + .extend({ + top: inner_width, + bottom: inner_width, + left: inner_width, + right: inner_width, + background: this.settings.inner_color, + }) + .toBuffer(); + } + + let outerBuffer = innerBuffer; + if (this.settings.outer_width) { + let outer_width = 0; + if (typeof this.settings.outer_width === "string") { + if (this.settings.outer_width.endsWith("%")) { + outer_width = Math.round( + (size * parseInt(this.settings.outer_width)) / 100, + ); + } else { + outer_width = parseInt(this.settings.outer_width); + } + } else { + outer_width = this.settings.outer_width; + } + outerBuffer = await sharp(innerBuffer) + .extend({ + top: outer_width, + bottom: outer_width, + left: outer_width, + right: outer_width, + background: this.settings.outer_color, + }) + .toBuffer(); + } + await post.platform.user.files.write(fileOut, outerBuffer); + await post.replaceFile(src, dst); + } + } +} diff --git a/src/plugins/ImageSize.ts b/src/plugins/ImageSize.ts new file mode 100644 index 0000000..62f63a2 --- /dev/null +++ b/src/plugins/ImageSize.ts @@ -0,0 +1,390 @@ +import { FileGroup, FileInfo } from "../types/index.ts"; + +import Plugin from "../models/Plugin.ts"; +import Post from "../models/Post.ts"; +import sharp from "sharp"; + +interface ImageSizeSettings { + fit?: "cover" | "contain"; + bgcolor?: string; + min_size?: number; + max_size?: number; + min_ratio?: number; + max_ratio?: number; + min_width?: number; + max_width?: number; + min_height?: number; + max_height?: number; +} + +/** + * Plugin ImageSize. + * + * Resize images from Post based on Platform limits. + * + */ +export default class ImageSize extends Plugin { + static defaults: ImageSizeSettings = { + fit: "contain", + bgcolor: "white", + min_size: 0, // kb + max_size: 0, // kb + min_ratio: 0, + max_ratio: 0, + min_width: 0, + max_width: 0, + min_height: 0, + max_height: 0, + }; + settings: ImageSizeSettings; + + constructor(settings?: object) { + super(); + this.settings = { + ...ImageSize.defaults, + ...(settings ?? {}), + }; + } + + /** + * Process the post + */ + + async process(post: Post): Promise { + post.platform.user.log.trace(this.id, post.id, "process"); + for (const file of post.getFiles(FileGroup.IMAGE)) { + await this.fixDimensions(post, file); + } + for (const file of post.getFiles(FileGroup.IMAGE)) { + await this.fixFileSize(post, file); + } + } + + private async fixDimensions(post: Post, file: FileInfo) { + if (file.width && file.height) { + const { imgw, imgh, canw, canh } = this.getDimensions( + file.width, + file.height, + this.settings.min_width, + this.settings.max_width, + this.settings.min_height, + this.settings.max_height, + this.settings.min_ratio, + this.settings.max_ratio, + this.settings.fit as "cover" | "contain", + ); + if ( + file.width !== imgw || + file.height !== imgh || + canw !== imgw || + canh !== imgh + ) { + post.platform.user.log.trace( + "ImageSize.fixDimensions", + file.name + + ":" + + file.width + + "x" + + file.height + + "=>" + + imgw + + "x" + + imgh + + "[" + + canw + + "x" + + canh + + "]", + ); + const newFileName = + post.platform.id + + "-" + + file.basename + + "-" + + imgw + + "x" + + imgh + + "." + + file.extension; + const src = file.name; + const dst = post.platform.assetsFolder + "/" + newFileName; + const padh = Math.floor((canh - imgh) / 2); + const padw = Math.floor((canw - imgw) / 2); + const fileIn = post.getFilePath(src); + const fileOut = post.getFilePath(dst); + const bufferIn = await post.platform.user.files.readBuffer(fileIn); + const bufferOut = await sharp(bufferIn) + .withMetadata() + .resize({ + width: imgw, + height: imgh, + }) + .extend({ + top: padh, + bottom: padh, + left: padw, + right: padw, + background: this.settings.bgcolor, + }) + .toBuffer(); + await post.platform.user.files.write(fileOut, bufferOut); + await post.replaceFile(src, dst); + } + } + } + + private async fixFileSize(post: Post, file: FileInfo) { + if (this.settings.min_size && file.size <= this.settings.min_size) { + throw post.platform.user.log.error( + "ImageSize.fixFileSize", + "Image is too small", + post.id + ":" + file.name + ":" + file.size, + ); + } + if (this.settings.max_size) { + await this.reduceFileSize(post, file, this.settings.max_size); + } + } + + private async reduceFileSize( + post: Post, + file: FileInfo, + maxkb: number, + ): Promise { + if (file.width && file.size / 1024 >= maxkb) { + if (file.mimetype !== "image/jpeg") { + const dst = + post.platform.assetsFolder + + "/" + + file.basename + + "-" + + file.extension + + ".jpg"; + post.platform.user.log.trace( + "ImageSize.reduceFileSize", + post.id + ":" + file.name + ": to jpg", + ); + const fileIn = post.getFilePath(file.name); + const fileOut = post.getFilePath(dst); + const bufferIn = await post.platform.user.files.readBuffer(fileIn); + const bufferOut = await sharp(bufferIn) + .keepExif() + .toFormat("jpg") // default q = 80 + .toBuffer(); + await post.platform.user.files.write(fileOut, bufferOut); + await post.replaceFile(file.name, dst); + file = await post.source.getFileInfo(dst, file.order); + } + } + if (file.width && file.size / 1024 >= maxkb) { + const newFileName = file.basename + "-small." + file.extension; + const dst = post.platform.assetsFolder + "/" + newFileName; + let newfile = await post.source.getFileInfo(file.name, file.order); + let factor = 1; + let count = 1; + while (newfile.size / 1024 >= maxkb) { + factor = factor * Math.sqrt((0.9 * maxkb) / (newfile.size / 1024)); + post.platform.user.log.trace( + "ImageSize.reduceFileSize", + post.id + ":" + file.name + ": scale " + factor, + ); + const fileIn = post.getFilePath(file.name); + const fileOut = post.getFilePath(dst); + const bufferIn = await post.platform.user.files.readBuffer(fileIn); + const bufferOut = await sharp(bufferIn) + .keepExif() + .resize({ + width: Math.round(file.width * factor), + }) + .toBuffer(); + await post.platform.user.files.write(fileOut, bufferOut); + newfile = await post.source.getFileInfo(dst, file.order); + if (count++ > 5) { + throw post.platform.user.log.error( + "ImageSize.reduceFileSize", + "Failed to scale down", + post.id + ":" + file.name + ":" + factor, + ); + } + } + await post.replaceFile(file.name, dst); + } + } + + private getDimensions( + imgw: number, + imgh: number, + minw = 0, + maxw = 0, + minh = 0, + maxh = 0, + minr = 0, + maxr = 0, + fit: "cover" | "contain" = "contain", + ): { imgw: number; imgh: number; canw: number; canh: number } { + if (minw && imgw < minw) { + imgh = (imgh * minw) / imgw; + imgw = minw; + // we scaled up to a minimum; if the height is now + // too big, we should crop height or pad width + // either way result is minw x maxh + if (maxh && imgh > maxh) { + if (fit === "cover") { + return this.croph(imgw, imgh, minw, maxh); + } + if (fit === "contain") { + return this.padw(imgw, imgh, minw, maxh); + } + } + } + if (minh && imgh < minh) { + imgw = (imgw * minh) / imgh; + imgh = minh; + // we scaled up to a minimum; if the width is now + // too big, we should crop width or pad height + // either way result is maxw x minh + if (maxw && imgw > maxw) { + if (fit === "cover") { + return this.cropw(imgw, imgh, maxw, minh); + } + if (fit === "contain") { + return this.padh(imgw, imgh, maxw, minh); + } + } + } + if (maxw && imgw > maxw) { + imgh = (imgh * maxw) / imgw; + imgw = maxw; + // we scaled down to a maximum; if the height is now + // too small, we should crop the width or pad the height + // either way result is maxw x minh + if (minh && imgh < minh) { + if (fit === "cover") { + return this.cropw(imgw, imgh, maxw, minh); + } + if (fit === "contain") { + return this.padh(imgw, imgh, maxw, minh); + } + } + } + if (maxh && imgh > maxh) { + imgw = (imgw * maxh) / imgh; + imgh = maxh; + // we scaled down to a maximum; if the width is now + // too small, we should crop the height or pad the width + // either way result is minw x maxh + if (minw && imgw < minw) { + if (fit === "cover") { + return this.croph(imgw, imgh, minw, maxh); + } + if (fit === "contain") { + return this.padw(imgw, imgh, minw, maxh); + } + } + } + const imgr = imgw / imgh; + if (maxr && imgr > maxr) { + // the image is too wide. either crop the width + // or pad the height till it fits + if (fit === "cover") { + const width = imgh * minr; + const height = imgh; + return this.cropw(imgw, imgh, width, height); + } + if (fit === "contain") { + const width = imgw; + const height = imgw / minr; + return this.padw(imgw, imgh, width, height); + } + } + if (minr && imgr < minr) { + // the image is too high. either crop the height + // or pad the width till it fits + if (fit === "cover") { + const width = imgw; + const height = imgw / minr; + return this.cropw(imgw, imgh, width, height); + } + if (fit === "contain") { + const width = imgh * minr; + const height = imgh; + return this.padw(imgw, imgh, width, height); + } + } + imgw = Math.round(imgw); + imgh = Math.round(imgh); + const canw = imgw; + const canh = imgh; + return { imgw, imgh, canw, canh }; + } + + /* + cropw: crop width; + scale the image so imgh = height, retain ratio + crop the image so canw = width + */ + private cropw( + imgw: number, + imgh: number, + width: number, + height: number, + ): { imgw: number; imgh: number; canw: number; canh: number } { + imgw = Math.round((imgw * height) / imgh); // scale up + imgh = Math.round(height); + const canw = Math.round(width); // crop + const canh = Math.round(height); + return { imgw, imgh, canw, canh }; + } + /* + croph: crop height; + scale the image so imgw = width, retain ratio + crop the image so canh = height + */ + private croph( + imgw: number, + imgh: number, + width: number, + height: number, + ): { imgw: number; imgh: number; canw: number; canh: number } { + imgh = Math.round((imgh * width) / imgw); + imgw = Math.round(width); + const canh = Math.round(height); // crop + const canw = Math.round(width); + return { imgw, imgh, canw, canh }; + } + + /* + padw: pad width; + scale the image so imgh = height, retain ratio + pads the image so canw = width + */ + private padw( + imgw: number, + imgh: number, + width: number, + height: number, + ): { imgw: number; imgh: number; canw: number; canh: number } { + imgw = Math.round((imgw * imgh) / height); + imgh = Math.round(height); + const canw = Math.round(width); // pad + const canh = imgh; + return { imgw, imgh, canw, canh }; + } + /* + padh: pad height; + scale the image so imgw = height, retain ratio + pads the image so canh = width + */ + private padh( + imgw: number, + imgh: number, + width: number, + height: number, + ): { imgw: number; imgh: number; canw: number; canh: number } { + imgh = Math.round((imgh * imgw) / width); + imgw = Math.round(width); + const canh = Math.round(height); // pad + const canw = imgw; + return { imgw, imgh, canw, canh }; + } +} diff --git a/src/plugins/LimitFiles.ts b/src/plugins/LimitFiles.ts new file mode 100644 index 0000000..23fb5c3 --- /dev/null +++ b/src/plugins/LimitFiles.ts @@ -0,0 +1,218 @@ +import { FileGroup } from "../types/index.ts"; +import Plugin from "../models/Plugin.ts"; +import Post from "../models/Post.ts"; + +interface LimitFilesSettings { + prefer?: FileGroup[]; + exclusive?: FileGroup[]; + total_max?: number; + total_min?: number; + image_min?: number; + image_max?: number; + video_min?: number; + video_max?: number; + text_min?: number; + text_max?: number; + other_min?: number; + other_max?: number; +} + +/** + * Plugin LimitFiles. + * + * Remove files from Post based on Platform limits. + * + */ +export default class LimitFiles extends Plugin { + static defaults: LimitFilesSettings = { + prefer: [FileGroup.VIDEO, FileGroup.IMAGE, FileGroup.TEXT, FileGroup.OTHER], + exclusive: [], + total_max: 0, + total_min: 0, + image_min: 0, + image_max: 0, + video_min: 0, + video_max: 0, + text_min: 0, + text_max: 0, + other_min: 0, + other_max: 0, + }; + settings: LimitFilesSettings = {}; + constructor(settings?: object) { + super(); + this.settings = { + ...LimitFiles.defaults, + ...(settings ?? {}), + }; + for (const defaultGroup of LimitFiles.defaults.prefer ?? []) { + if (!this.settings.prefer?.includes(defaultGroup)) { + this.settings.prefer?.push(defaultGroup); + } + } + } + + /** + * Process the post + */ + + async process(post: Post): Promise { + post.platform.user.log.trace(this.id, post.id, "process"); + + if (this.settings.total_min) { + if (post.getFiles().length < this.settings.total_min) { + post.platform.user.log.trace( + this.id, + post.id, + "total_min", + "Invalidate post", + ); + post.valid = false; + return; + } + } + if (this.settings.image_min) { + if (post.getFiles(FileGroup.IMAGE).length < this.settings.image_min) { + post.platform.user.log.trace( + this.id, + post.id, + "image_min", + "Invalidate post", + ); + post.valid = false; + return; + } + } + if (this.settings.video_min) { + if (post.getFiles(FileGroup.VIDEO).length < this.settings.video_min) { + post.platform.user.log.trace( + this.id, + post.id, + "video_min", + "Invalidate post", + ); + post.valid = false; + return; + } + } + if (this.settings.text_min) { + if (post.getFiles(FileGroup.TEXT).length < this.settings.text_min) { + post.platform.user.log.trace( + this.id, + post.id, + "text_min", + "Invalidate post", + ); + post.valid = false; + return; + } + } + if (this.settings.other_min) { + if (post.getFiles(FileGroup.OTHER).length < this.settings.other_min) { + post.platform.user.log.trace( + this.id, + post.id, + "other_min", + "Invalidate post", + ); + post.valid = false; + return; + } + } + + if (this.settings.exclusive?.length) { + for (const exclusiveGroup of this.settings.exclusive) { + if (post.hasFiles(exclusiveGroup as FileGroup)) { + post.platform.user.log.trace( + this.id, + post.id, + "exclusive", + "Remove all files except " + exclusiveGroup, + ); + for (const removeGroup of Object.values(FileGroup)) { + if (removeGroup !== exclusiveGroup) { + post.removeFiles(removeGroup); + } + } + break; + } + } + } + + if (this.settings.image_max) { + const numfiles = post.getFiles(FileGroup.IMAGE).length; + if (numfiles > this.settings.image_max) { + post.platform.user.log.trace( + this.id, + post.id, + "image_max", + "Limit images to " + this.settings.image_max, + ); + post.limitFiles(FileGroup.IMAGE, this.settings.image_max); + } + } + if (this.settings.video_max) { + const numfiles = post.getFiles(FileGroup.VIDEO).length; + if (numfiles > this.settings.video_max) { + post.platform.user.log.trace( + this.id, + post.id, + "video_max", + "Limit video to " + this.settings.video_max, + ); + post.limitFiles(FileGroup.VIDEO, this.settings.video_max); + } + } + if (this.settings.text_max) { + const numfiles = post.getFiles(FileGroup.TEXT).length; + if (numfiles > this.settings.text_max) { + post.platform.user.log.trace( + this.id, + post.id, + "text_max", + "Limit text to " + this.settings.text_max, + ); + post.limitFiles(FileGroup.TEXT, this.settings.text_max); + } + } + if (this.settings.other_max) { + const numfiles = post.getFiles(FileGroup.OTHER).length; + if (numfiles > this.settings.other_max) { + post.platform.user.log.trace( + this.id, + post.id, + "other_max", + "Limit other to " + this.settings.other_max, + ); + post.limitFiles(FileGroup.OTHER, this.settings.other_max); + } + } + if (this.settings.total_max) { + let remaining = this.settings.total_max; + for (const preferGroup of this.settings.prefer ?? + Object.values(FileGroup)) { + if (remaining) { + const numfiles = post.getFiles(preferGroup as FileGroup).length; + if (numfiles > this.settings.total_max) { + post.platform.user.log.trace( + this.id, + post.id, + "total_max", + "Limit " + preferGroup + " to " + remaining, + ); + post.limitFiles(preferGroup as FileGroup, remaining); + } + remaining = Math.max(remaining - numfiles, 0); + } else { + post.platform.user.log.trace( + this.id, + post.id, + "total_max", + "Remove all " + preferGroup, + ); + post.removeFiles(preferGroup as FileGroup); + } + } + } + } +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..3d39c44 --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,3 @@ +export { default as LimitFiles } from "./LimitFiles.ts"; +export { default as ImageSize } from "./ImageSize.ts"; +export { default as ImageFrame } from "./ImageFrame.ts"; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..dc54e5b --- /dev/null +++ b/src/server.ts @@ -0,0 +1,18 @@ +/* + 202501*pike + Fairpost cli to start server +*/ + +import "./bootstrap.ts"; + +import Fairpost from "./services/Fairpost.ts"; +import { JSONReplacer } from "./utilities.ts"; +import Operator from "./models/Operator.ts"; + +async function main() { + const operator = new Operator("admin", ["admin"], "cli", true); + const output = await Fairpost.execute(operator, undefined, "serve"); + console.log(JSON.stringify(output, JSONReplacer, "\t")); +} +// eslint-disable-next-line @typescript-eslint/no-floating-promises +main(); diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts new file mode 100644 index 0000000..1b53103 --- /dev/null +++ b/src/services/AuthService.ts @@ -0,0 +1,94 @@ +import argon2 from "argon2"; +import { randomBytes } from "crypto"; +import User from "../models/User.ts"; + +/** + * AuthService handles authentication; wrapping + * fairposts simple username/password auth or eg amazon cognito + * as decided by by FAIRPOST_USER_AUTH + */ + +export default class AuthService { + public static async setPassword(user: User, pass: string) { + switch (process.env.FAIRPOST_USER_AUTH) { + default: { + const crypted = await argon2.hash(pass); + user.log.info("AuthService", "Setting password .."); + user.data.set("auth", "FAIRPOST_PASSWORD", crypted); + await user.data.save(); + } + } + } + + public static async generateToken( + user: User, + ): Promise<{ token: string; timeout: Date }> { + switch (process.env.FAIRPOST_USER_AUTH) { + default: { + const token = randomBytes(32).toString("hex"); + const timeout = new Date(); + timeout.setHours(timeout.getHours() + 1); + user.data.set("auth", "FAIRPOST_ACCESS_TOKEN", token); + user.data.set("auth", "FAIRPOST_ACCESS_EXPIRY", timeout.toISOString()); + await user.data.save(); + return { token, timeout }; + } + } + } + + public static async getToken(user: User): Promise { + switch (process.env.FAIRPOST_USER_AUTH) { + default: { + const timeout = user.data.get("auth", "FAIRPOST_ACCESS_EXPIRY", ""); + if (timeout) { + if (new Date() < new Date(timeout)) { + return user.data.get("auth", "FAIRPOST_ACCESS_TOKEN"); + } else { + await AuthService.logout(user); + throw user.log.error("AuthService", "getToken: token timed out"); + } + } else { + throw user.log.error("AuthService", "getToken: no token available"); + } + } + } + } + + public static async login(user: User, pass?: string): Promise { + switch (process.env.FAIRPOST_USER_AUTH) { + default: { + if (pass) { + const crypted = user.data.get("auth", "FAIRPOST_PASSWORD"); + if (await argon2.verify(crypted, pass)) { + const result = await AuthService.generateToken(user); + return result.token; + } else { + throw user.log.error("AuthService.login", "Wrong password"); + } + } else { + throw user.log.error("AuthService.login", "Missing password"); + } + } + } + } + public static async verifyToken(user: User, token: string): Promise { + switch (process.env.FAIRPOST_USER_AUTH) { + default: { + // masquerading: + // const authUserId = user.data.get('auth','managed-by',user.id); + const userToken = await AuthService.getToken(user); + if (token !== userToken) { + user.log.error("AuthService", "verifyToken: tokens dont match"); + return false; + } + return true; + } + } + } + + public static async logout(user: User) { + user.data.del("auth", "FAIRPOST_ACCESS_TOKEN"); + user.data.del("auth", "FAIRPOST_ACCESS_EXPIRY"); + await user.data.save(); + } +} diff --git a/src/services/Fairpost.ts b/src/services/Fairpost.ts new file mode 100644 index 0000000..73751a6 --- /dev/null +++ b/src/services/Fairpost.ts @@ -0,0 +1,1116 @@ +import log4js from "log4js"; +import log4jsConfig from "../config/log4js.json" with { type: "json" }; + +import { + CommandArguments, + CombinedResult, + ProcessedFieldMapping, +} from "../types/index.ts"; +import { PlatformId } from "../platforms/index.ts"; +import { + FeedDto, + PlatformDto, + PostDto, + PostStatus, + SourceDto, + UserDto, + SourceStage, +} from "../types/index.ts"; + +import Post from "../models/Post.ts"; +import Server from "../services/Server.ts"; +import AuthService from "../services/AuthService.ts"; +import Operator from "../models/Operator.ts"; +import User from "../models/User.ts"; + +type FairpostOutput = + | ProcessedFieldMapping + | FeedDto + | PlatformDto + | PostDto + | SourceDto + | UserDto + | FeedDto[] + | PlatformDto[] + | PostDto[] + | SourceDto[] + | UserDto[] + | CombinedResult[] + | { + [id in PlatformId]?: CombinedResult | CombinedResult[]; + } + | { success: boolean; message?: string; messages?: string[] }; + +/** + * Fairpost - singleton + * + * A command handler for the Fairpost framework + * Fairpost has its own logger, but the commands user has their own logs too. + */ + +class Fairpost { + static instance: Fairpost; + public logger: log4js.Logger; + constructor() { + if (Fairpost.instance) { + throw new Error("CommandHandler: call getInstance() instead"); + } + log4js.configure(log4jsConfig); + this.logger = log4js.getLogger("default"); + } + /** + * Get the instance of the singleton + */ + + static getInstance(): Fairpost { + if (!Fairpost.instance) { + Fairpost.instance = new Fairpost(); + } + return Fairpost.instance; + } + + /** + * Execute a command + * @param operator - the operator executing the command + * @param user - the user executing the command, if any + * @param command - the command to execute + * @param args - the arguments for the command + * @returns a promise that resolves to the output of the command + * @throws Error if the command is not recognized or if the user does not have the required permissions + * @throws Error if the command fails + */ + + async execute( + operator: Operator, + user?: User, + command: string = "help", + args: CommandArguments = {}, + ): Promise { + try { + let output: undefined | FairpostOutput = undefined; + + this.logger.info( + "Fairpost ", + operator.id, + user?.id ?? "", + command, + args.dryrun ? " dry-run" : "", + ); + if (user) { + user.log.info( + "Fairpost ", + operator.id, + command, + args.dryrun ? " dry-run" : "", + ); + } + + operator.validate(); + const permissions = operator.getPermissions(user); + //console.log(operator,permissions); + switch (command) { + case "create-user": { + if (!permissions.manageUsers) { + throw new Error("Missing permissions for command " + command); + } + if (!args.user) { + throw new Error("user is required for command " + command); + } + const newUser = await User.createUser(args.user); + if (args.password) { + await AuthService.setPassword(newUser, args.password); + } + output = await newUser.mapper.getDto(operator); + break; + } + + case "login": { + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.password) { + throw new Error("password is required for command " + command); + } + const token = await AuthService.login(user, args.password); + output = { success: !!token }; + break; + } + + case "logout": { + if (!permissions.manageAccount) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + await AuthService.logout(user); + output = { success: true }; + break; + } + + case "set-password": { + if (!permissions.manageAccount) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.password) { + throw new Error("password is required for command " + command); + } + await AuthService.setPassword(user, args.password); + output = { success: true }; + break; + } + + case "get-fields": { + if (!permissions.manageAccount) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.model) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: model", + ); + } + let instance: object | undefined = undefined; + if (args.model === "platform") { + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + instance = user.getPlatform(args.platform); + } + + output = operator.getFieldMapping(user, args.model, instance); + break; + } + + case "refresh-token": { + if (!permissions.manageAccount) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const result = await AuthService.generateToken(user); + output = { success: true, result: result.token }; + break; + } + + case "get-users": { + const users = await User.getUsers(!permissions.manageUsers); + output = await Promise.all( + users.map((user) => user.mapper.getDto(operator)), + ); + break; + } + + case "get-user": { + if (!user) { + throw new Error("Missing user for command " + command); + } + output = await user.mapper.getDto(operator); + break; + } + + case "put-user": { + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.payload) { + throw user.log.error( + "CommandHandler " + command, + "Missing payload", + ); + } + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Payload must be an object", + ); + } + await user.mapper.putDto(operator, args.payload as UserDto); + output = await user.mapper.getDto(operator); + break; + } + + case "get-feed": { + if (!permissions.manageFeed) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const feed = user.getFeed(); + output = await feed.mapper.getDto(operator); + break; + } + + case "put-feed": { + if (!permissions.manageFeed) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.payload) { + throw user.log.error( + "CommandHandler " + command, + "Missing payload", + ); + } + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Payload must be an object", + ); + } + const feed = user.getFeed(); + await feed.mapper.putDto(operator, args.payload as FeedDto); + output = await feed.mapper.getDto(operator); + break; + } + + case "connect-platform": { + if (!permissions.manageFeed) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Connect payload must be an object", + ); + } + const platform = await user.addPlatform(args.platform); + const result = await platform.connect( + operator, + args.payload as object, + ); + output = { + [args.platform]: { + success: true, + result: result, + }, + }; + break; + } + + case "get-platform": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const platform = user.getPlatform(args.platform); + output = await platform.mapper.getDto(operator); + break; + } + case "put-platform": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + if (!args.payload) { + throw user.log.error( + "CommandHandler " + command, + "Missing payload", + ); + } + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Payload must be an object", + ); + } + const platform = user.getPlatform(args.platform); + output = { + [args.platform]: { + success: await platform.mapper.putDto( + operator, + args.payload as PlatformDto, + ), + result: await platform.mapper.getDto(operator), + }, + }; + break; + } + + case "get-platforms": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const platforms = user.getPlatforms(args.platforms); + output = await Promise.all( + platforms.map((p) => p.mapper.getDto(operator)), + ); + + break; + } + case "test-platform": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const platform = user.getPlatform(args.platform); + output = { + [args.platform]: { + success: true, + result: await platform.test(), + }, + }; + break; + } + case "test-platforms": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const platforms = user.getPlatforms(); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const platform of platforms) { + try { + output[platform.id] = { + success: true, + result: await platform.test(), + }; + } catch (e) { + output[platform.id] = { + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + break; + } + case "refresh-platform": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const platform = user.getPlatform(args.platform); + const refreshed = await platform.refresh(); + output = { + [args.platform]: { + success: true, + message: refreshed + ? "Platform refreshed" + : "Platform not refreshed", + }, + }; + break; + } + case "refresh-platforms": { + if (!permissions.managePlatforms) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const platform of platforms) { + try { + const refreshed = await platform.refresh(); + output[platform.id] = { + success: true, + result: refreshed + ? "Platform refreshed" + : "Platform not refreshed", + }; + } catch (e) { + output[platform.id] = { + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + break; + } + case "get-source": { + if (!permissions.manageSources) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + const feed = user.getFeed(); + const source = await feed.getSource(args.source, args.stage); + output = await source.mapper.getDto(operator); + break; + } + case "put-source": { + if (!permissions.manageSources) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.payload) { + throw user.log.error( + "CommandHandler " + command, + "Missing payload", + ); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Payload must be an object", + ); + } + const feed = user.getFeed(); + const source = await feed.getSource(args.source, args.stage); + await source.mapper.putDto(operator, args.payload as SourceDto); + output = await source.mapper.getDto(operator); + break; + } + case "get-sources": { + if (!permissions.manageSources) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + const feed = user.getFeed(); + const sources = await feed.getSources(args.sources, args.stage); + output = await Promise.all( + sources.map((source) => source.mapper.getDto(operator)), + ); + break; + } + + case "get-post": { + if (!permissions.readPosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const feed = user.getFeed(); + const platform = user.getPlatform(args.platform); + const source = await feed.getSource(args.source); + const post = await platform.getPost(source); + output = await post.mapper.getDto(operator); + break; + } + case "put-post": { + if (!permissions.managePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.payload) { + throw user.log.error( + "CommandHandler " + command, + "Missing payload", + ); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + + if ( + Buffer.isBuffer(args.payload || typeof args.payload === "string") + ) { + throw user.log.error( + "CommandHandler " + command, + "Payload must be an object", + ); + } + const feed = user.getFeed(); + const platform = user.getPlatform(args.platform); + const source = await feed.getSource(args.source); + const post = await platform.getPost(source); + await post.mapper.putDto(operator, args.payload as PostDto); + output = await post.mapper.getDto(operator); + break; + } + case "get-posts": { + if (!permissions.readPosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.sources && args.source) { + args.sources = [args.source]; + } + const feed = user.getFeed(); + const platforms = user.getPlatforms(args.platforms); + const sources = await feed.getSources(args.sources, args.stage); + const posts = [] as Post[]; + for (const platform of platforms) { + posts.push(...(await platform.getPosts(sources, args.status))); + } + output = await Promise.all( + posts.map((p) => p.mapper.getDto(operator)), + ); + break; + } + case "prepare-post": { + if (!permissions.managePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const platform = user.getPlatform(args.platform); + const feed = user.getFeed(); + const source = await feed.getSource(args.source); + const post = await platform.preparePost(source); + output = await post.mapper.getDto(operator); + break; + } + case "prepare-posts": { + if (!permissions.managePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.sources && args.source) { + args.sources = [args.source]; + } + // by default, prepare posts from incoming + if (!args.sources && !args.stage) { + args.stage = SourceStage.INCOMING; + } + const feed = user.getFeed(); + const sources = await feed.getSources(args.sources, args.stage); + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]?: CombinedResult[] }; + for (const platform of platforms) { + for (const source of sources) { + if (!output[platform.id]) { + output[platform.id] = []; + } + try { + const post = await platform.preparePost(source); + (output[platform.id] as CombinedResult[]).push({ + success: true, + result: await post.mapper.getDto(operator), + }); + } catch (e) { + user.log.error("Fairpost", "preparePosts", e); + (output[platform.id] as CombinedResult[]).push({ + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }); + } + } + } + break; + } + case "set-status": { + if (!permissions.schedulePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.sources && args.source) { + args.sources = [args.source]; + } + if (!args.sources) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: sources", + ); + } + if (!args.status) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: status", + ); + } + const feed = user.getFeed(); + const sources = await feed.getSources(args.sources); + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const source of sources) { + for (const platform of platforms) { + try { + const post = await platform.getPost(source); + await post.setStatus(args.status); + output[platform.id] = { + success: true, + result: await post.mapper.getDto(operator), + }; + } catch (e) { + output[platform.id] = { + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + } + break; + } + case "schedule-post": { + if (!permissions.schedulePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + if (!args.date) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: date", + ); + } + const feed = user.getFeed(); + const source = await feed.getSource(args.source); + const platform = user.getPlatform(args.platform); + const post = await platform.getPost(source); + await post.schedule(args.date); + output = await post.mapper.getDto(operator); + break; + } + case "schedule-posts": { + if (!permissions.schedulePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.source && args.sources) { + args.source = args.sources[0]; + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.date) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: date", + ); + } + const feed = user.getFeed(); + const source = await feed.getSource(args.source); + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const platform of platforms) { + try { + const post = await platform.getPost(source); + await post.schedule(args.date); + output[platform.id] = { + success: true, + result: await post.mapper.getDto(operator), + }; + } catch (e) { + output[platform.id] = { + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + break; + } + case "schedule-next-post": { + if (!permissions.schedulePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + + const platform = user.getPlatform(args.platform); + const feed = user.getFeed(); + const sources = + args.sources || args.stage + ? await feed.getSources(args.sources, args.stage) + : ( + await Promise.all([ + feed.getSources(undefined, SourceStage.PENDING), + feed.getSources(undefined, SourceStage.ACTIVE), + ]) + ).flat(); + const post = await platform.scheduleNextPost( + args.date ? new Date(args.date) : undefined, + sources, + !!args.stage, + ); + if (post) { + output = await post.mapper.getDto(operator); + } else { + output = { success: false, message: "No post left to schedule" }; + } + break; + } + case "publish-post": { + if (!permissions.publishPosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + if (!args.platform) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: platform", + ); + } + const platform = user.getPlatform(args.platform); + const feed = user.getFeed(); + const source = await feed.getSource(args.source); + const post = await platform.getPost(source); + output = { + [platform.id]: { + success: await post.publish(!!args.dryrun), + dryrun: !!args.dryrun, + result: post.link ?? "#nolink", + }, + }; + break; + } + case "publish-posts": { + if (!permissions.publishPosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.source && args.sources) { + args.source = args.sources[0]; + } + if (!args.source) { + throw user.log.error( + "CommandHandler " + command, + "Missing argument: source", + ); + } + const feed = user.getFeed(); + const source = await feed.getSource(args.source); + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const platform of platforms) { + try { + const post = await platform.getPost(source); + output[platform.id] = { + success: await post.publish(!!args.dryrun), + dryrun: !!args.dryrun, + result: post.link, + }; + } catch (e) { + output[platform.id] = { + success: false, + dryrun: !!args.dryrun, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + break; + } + + /* feed planning */ + case "schedule-next-posts": { + if (!permissions.schedulePosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + if (!args.platforms && args.platform) { + args.platforms = [args.platform]; + } + if (!args.sources && args.source) { + args.sources = [args.source]; + } + + const feed = user.getFeed(); + const sources = + args.sources || args.stage + ? await feed.getSources(args.sources, args.stage) + : ( + await Promise.all([ + feed.getSources(undefined, SourceStage.PENDING), + feed.getSources(undefined, SourceStage.ACTIVE), + ]) + ).flat(); + const platforms = user.getPlatforms(args.platforms); + const posts = [] as Post[]; + for (const platform of platforms) { + const post = await platform.scheduleNextPost( + args.date ? new Date(args.date) : undefined, + sources, + !!args.stage, + ); + if (post) posts.push(post); + } + output = await Promise.all( + posts.map((p) => p.mapper.getDto(operator)), + ); + break; + } + case "publish-due-posts": { + if (!permissions.publishPosts) { + throw new Error("Missing permissions for command " + command); + } + if (!user) { + throw new Error("user is required for command " + command); + } + // by default, publist due posts from active, + // because that is where scheduled posts are + if (!args.sources && !args.stage) { + args.stage = SourceStage.ACTIVE; + } + const feed = user.getFeed(); + const sources = await feed.getSources(args.sources, args.stage); + const platforms = user.getPlatforms(args.platforms); + output = {} as { [id in PlatformId]: CombinedResult }; + for (const platform of platforms) { + try { + const post = await platform.publishDuePost( + sources, + !!args.dryrun, + ); + if (post) { + output[platform.id] = { + success: + post.status === PostStatus.PUBLISHED || !!args.dryrun, + result: post.link, + }; + } else { + output[platform.id] = { + success: true, + message: "No posts due", + }; + } + } catch (e) { + output[platform.id] = { + success: false, + message: e instanceof Error ? e.message : JSON.stringify(e), + }; + } + } + break; + } + + case "serve": { + if (!permissions.manageServer) { + throw new Error("Missing permissions for command " + command); + } + output = { + success: true, + message: await Server.serve(), + }; + + break; + } + + default: { + const cmd = "fairpost:"; + output = { + success: true, + messages: [ + "# basic commands:", + `${cmd} help`, + `${cmd} @userid get-user`, + `${cmd} @userid put-user << payload`, + `${cmd} @userid edit-user (cli only)`, + `${cmd} @userid get-feed`, + `${cmd} @userid put-feed << payload`, + `${cmd} @userid edit-feed (cli only)`, + `${cmd} @userid get-platform --platform=xxx`, + `${cmd} @userid put-platform --platform=xxx << payload`, + `${cmd} @userid edit-platform --platform=xxx (cli only)`, + `${cmd} @userid get-platforms [--platforms=xxx,xxx]`, + `${cmd} @userid connect-platform --platform=xxx`, + `${cmd} @userid test-platform --platform=xxx`, + `${cmd} @userid test-platforms [--platforms=xxx,xxx]`, + `${cmd} @userid refresh-platform --platform=xxx`, + `${cmd} @userid refresh-platforms [--platforms=xxx,xxx]`, + `${cmd} @userid get-source --source=xxx [--stage=xxx] `, + `${cmd} @userid put-source << payload`, + `${cmd} @userid edit-source (cli only)`, + `${cmd} @userid get-sources [--sources=xxx,xxx|--stage=xxx]`, + `${cmd} @userid get-post --post=xxx:xxx`, + `${cmd} @userid put-post << payload`, + `${cmd} @userid edit-post (cli only)`, + `${cmd} @userid get-posts [--status=xxx] [--sources=xxx,xxx|--stage=xxx] [--platforms=xxx,xxx] `, + `${cmd} @userid prepare-post --post=xxx:xxx`, + `${cmd} @userid schedule-post --post=xxx:xxx --date=xxxx-xx-xx `, + `${cmd} @userid schedule-posts [--source=xxx] [--platforms=xxx,xxx|--platform=xxx] --date=xxxx-xx-xx`, + `${cmd} @userid schedule-next-post --platform=xxx [--date=xxxx-xx-xx] [--sources=xxx,xxx|--stage=xxx]`, + `${cmd} @userid publish-post --post=xxx:xxx [--dry-run]`, + `${cmd} @userid publish-posts [--source=xxx] [--platforms=xxx,xxx|--platform=xxx]`, + `${cmd} @userid set-status [--post=xxx:xxx|--posts=xxx:xxx,xxx:xxx]`, + `${cmd} @userid get-fields --model=user|feed|platform|source|post [--platform=xxx]`, + "\n# feed planning:", + `${cmd} @userid prepare-posts [--sources=xxx,xxx|--source=xxx|--stage=xxx] [--platforms=xxx,xxx|--platform=xxx]`, + `${cmd} @userid schedule-next-posts [--date=xxxx-xx-xx] [--sources=xxx,xxx|--stage] [--platforms=xxx,xxx] `, + `${cmd} @userid publish-due-posts [--sources=xxx,xxx|--stage=xxx] [--platforms=xxx,xxx] [--dry-run]`, + "\n# account mgmt:", + `${cmd} @userid login --password=xxx`, + `${cmd} @userid logout`, + `${cmd} @userid set-password --password=xxx`, + `${cmd} @userid refresh-token`, + "\n# admin only:", + `${cmd} @userid create-user`, + `${cmd} serve`, + ], + }; + } + } + if (!output) { + throw this.logger.error("Fairpost.execute", "no output"); + } + return output; + } catch (e) { + this.logger.error("Fairpost.execute", e); + // the caller may handle the error + throw e; + } + } +} + +export default Fairpost.getInstance(); diff --git a/src/services/GlobalFs.ts b/src/services/GlobalFs.ts new file mode 100644 index 0000000..3ce2720 --- /dev/null +++ b/src/services/GlobalFs.ts @@ -0,0 +1,196 @@ +import { basename, extname, resolve } from "path"; +import { Readable } from "stream"; + +import { + FileStorage, + DirectoryListing, + FileContents, + StatEntry, +} from "@flystorage/file-storage"; +import { LocalStorageAdapter } from "@flystorage/local-fs"; + +/** + * GlobalFs is a wrapper around flystorage, not tied to a user; + */ + +export default class GlobalFs { + public storage: FileStorage; + + constructor() { + switch (process.env.FAIRPOST_FILE_SYSTEM) { + default: { + const adapter = new LocalStorageAdapter( + resolve(import.meta.dirname + "/../../"), + ); + this.storage = new FileStorage(adapter); + } + } + } + + public async stat(path: string): Promise { + return await this.storage.stat(path); + } + + public async exists(path: string): Promise { + try { + return !!(await this.storage.stat(path)); + } catch { + return false; + } + } + + public async isDir(path: string): Promise { + try { + return await this.storage.directoryExists(path); + } catch { + return false; + } + } + public async isFile(path: string): Promise { + try { + return await this.storage.fileExists(path); + } catch { + return false; + } + } + + public list(path: string, options?: { deep?: boolean }): DirectoryListing { + return this.storage.list(path, options); + } + public async mkdir(path: string): Promise { + return await this.storage.createDirectory(path); + } + public async read(path: string): Promise { + return await this.storage.read(path); + } + public async readFile(path: string): Promise { + return await this.storage.readToString(path); + } + public async readBuffer(path: string): Promise { + return await this.storage.readToBuffer(path); + } + public async write(path: string, contents: FileContents): Promise { + return await this.storage.write(path, contents); + } + public async copy( + src: string, + dst: string, + checkForDir = true, + ): Promise { + if (checkForDir && (await this.isDir(src))) { + await this.copyDir(src, dst); + return; + } + return await this.storage.copyFile(src, dst); + } + + public async copyDir( + sourceDir: string, + destinationDir: string, + ): Promise { + const log: string[] = []; + const createDirectoryPromises = []; + for await (const entry of this.list(sourceDir, { deep: true })) { + if (entry.type === "directory" || entry.isDirectory) { + const sourcePath = entry.path; + const relativePath = sourcePath + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("creating dir " + destinationPath); + createDirectoryPromises.push(this.mkdir(destinationPath)); + } + } + await Promise.all(createDirectoryPromises); + + const copyFilePromises = []; + for await (const entry of this.list(sourceDir, { deep: true })) { + if (entry.type === "file" || entry.isFile) { + const sourcePath = entry.path; + const relativePath = sourcePath + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("copying file " + entry.path + " -> " + destinationPath); + copyFilePromises.push(this.copy(entry.path, destinationPath, true)); + } + } + await Promise.all(copyFilePromises); + + return log; + } + + public async move( + src: string, + dst: string, + checkForDir = true, + ): Promise { + if (checkForDir && (await this.isDir(src))) { + await this.moveDir(src, dst); + return; + } + return await this.storage.moveFile(src, dst); + } + + public async moveDir( + sourceDir: string, + destinationDir: string, + ): Promise { + const log: string[] = []; + + const createDirectoryPromises = []; + for await (const item of this.list(sourceDir, { deep: true })) { + if (item.isDirectory) { + const relativePath = item.path + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("creating dir " + destinationPath); + createDirectoryPromises.push( + this.storage.createDirectory(destinationPath), + ); + } + } + await Promise.all(createDirectoryPromises); + + const moveFilePromises = []; + for await (const item of this.list(sourceDir, { deep: true })) { + if (item.isFile) { + const relativePath = item.path + .slice(sourceDir.length) + .replace(/^\/+/, ""); + const destinationPath = `${destinationDir}/${relativePath}`; + log.push("moving file " + item.path + "," + destinationPath); + moveFilePromises.push(this.move(item.path, destinationPath, true)); + } + } + await Promise.all(moveFilePromises); + + log.push("deleting dir " + sourceDir); + await this.storage.deleteDirectory(sourceDir); + return log; + } + + public async getMimeType(path: string): Promise { + try { + return await this.storage.mimeType(path); + } catch { + return "application/unknown"; + } + } + public async getSize(path: string): Promise { + return await this.storage.fileSize(path); + } + public async getTimestamp(path: string): Promise { + return await this.storage.lastModified(path); + } + public slugify(name: string) { + const ext = extname(name).toLowerCase(); + const base = basename(name, ext) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return base + ext; + } +} diff --git a/src/services/OAuth2Service.ts b/src/services/OAuth2Service.ts new file mode 100644 index 0000000..9fc4ef9 --- /dev/null +++ b/src/services/OAuth2Service.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "fs"; +import { createServer, IncomingMessage, ServerResponse } from "http"; +import { parse } from "url"; + +class DeferredResponseQuery { + promise: Promise<{ [key: string]: string | string[] }>; + reject: Function = () => {}; // eslint-disable-line + resolve: Function = () => {}; // eslint-disable-line + constructor() { + this.promise = new Promise((resolve, reject) => { + this.reject = reject; + this.resolve = resolve; + }); + } +} + +/** + * OAuth2Service: Static service to launch a webserver for + * requesting remote permissions on a service + */ +export default class OAuth2Service { + public static getRequestUrl(clientHost: string, clientPort: number): string { + return `http://${clientHost}:${clientPort}`; + } + + public static getCallbackUrl(clientHost: string, clientPort: number): string { + return this.getRequestUrl(clientHost, clientPort) + "/callback"; + } + + /** + * Request remote permissions + * + * starts a webserver on host:port, showing a page with + * serviceLink on it. Keeps the webserver open until the + * client returns with a code, then stops the server and + * resolves the query passed. + * @param serviceName - the name of the remote platform + * @param serviceLink - the uri to the remote platform + * @returns a flat object of returned query + */ + + public static async requestRemotePermissions( + serviceName: string, + serviceLink: string, + clientHost: string, + clientPort: number, + ): Promise<{ [key: string]: string | string[] }> { + const server = createServer(); + const deferred = new DeferredResponseQuery(); + + server.listen(clientPort, clientHost, () => { + console.log( + `Open a web browser and go to ${this.getRequestUrl( + clientHost, + clientPort, + )}`, + ); + }); + const requestListener = async function ( + request: IncomingMessage, + response: ServerResponse, + ) { + const parsed = parse(request.url ?? "/", true); + if (parsed.pathname === "/callback") { + let result = ""; + for (const key in parsed.query) { + result += key + " : " + String(parsed.query[key]) + "\n"; + } + let body = readFileSync("public/auth/callback.html", "utf8"); + body = body.replace(/{{serviceName}}/g, serviceName); + body = body.replace(/{{result}}/g, result ?? "UNKNOWN"); + response.setHeader("Content-Type", "text/html"); + response.setHeader("Connection", "close"); + response.writeHead(200); + response.end(body); + server.close(); + deferred.resolve(parsed.query); + } else { + let body = readFileSync("public/auth/request.html", "utf8"); + body = body.replace(/{{serviceLink}}/g, serviceLink); + body = body.replace(/{{serviceName}}/g, serviceName); + response.setHeader("Content-Type", "text/html"); + response.writeHead(200); + response.end(body); + } + }; + server.on("request", requestListener); + + return deferred.promise; + } +} diff --git a/src/services/Server.ts b/src/services/Server.ts new file mode 100644 index 0000000..00224e5 --- /dev/null +++ b/src/services/Server.ts @@ -0,0 +1,234 @@ +import cookie from "cookie"; +import { createReadStream } from "fs"; +import { createServer, IncomingMessage, ServerResponse } from "http"; + +import Fairpost from "./Fairpost.ts"; +import AuthService from "./AuthService.ts"; +import { JSONReplacer, parsePayload } from "../utilities.ts"; +import { PlatformId } from "../platforms/index.ts"; +import { SourceStage, PostStatus } from "../types/index.ts"; +import Operator from "../models/Operator.ts"; +import User from "../models/User.ts"; + +/** + * Server: start a webserver for an REST api + */ +export default class Server { + public static async serve(): Promise { + process.env.FAIRPOST_UI = "api"; + const host = process.env.FAIRPOST_SERVER_BIND; + const port = Number(process.env.FAIRPOST_SERVER_PORT); + return await new Promise((resolve) => { + const server = createServer((req, res) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Server.handleRequest(req, res); + }); + server.listen(port, host, () => { + resolve(`Fairpost REST Api running on ${host}:${port}`); + }); + }); + } + + public static async handleRequest( + request: IncomingMessage, + response: ServerResponse, + ) { + // enable CORS + response.setHeader( + "Access-Control-Allow-Origin", + process.env.FAIRPOST_SERVER_CORS ?? "*", + ); + response.setHeader("Access-Control-Request-Method", "*"); + response.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Headers", "*"); + + if (request.method === "OPTIONS") { + response.writeHead(200); + response.end(); + return; + } + + // handle favico + if (request.url === "/favicon.ico") { + const fileStream = createReadStream("public/fairpost-icon.png"); + response.writeHead(200, { "Content-Type": "image/png" }); + fileStream.pipe(response); + return; + } + + Fairpost.logger.trace("Server.handleRequest", "start", request.url); + + const parsed = new URL( + request.url?.replace(/\/+/, "/") ?? "/", + `${request.headers.protocol}://${request.headers.host}`, + ); + + // read userid and command from path + let username = undefined, + userid = undefined, + command = undefined; + const [part1, part2] = parsed.pathname?.split("/").slice(1) ?? ["", ""]; + if (part1.startsWith("@")) { + username = part1; + userid = part1.replace("@", ""); + command = part2; + } else { + command = part1; + } + + // read other params from query + const password = parsed.searchParams.get("password") || undefined; + const model = parsed.searchParams.get("model") || undefined; + const dryrun = parsed.searchParams.get("dry-run") === "true"; + const date = parsed.searchParams.get("date"); + const post = parsed.searchParams.get("post"); + const [source, platform] = post + ? (post.split(":") as [string, PlatformId]) + : [ + parsed.searchParams.get("source") || undefined, + (parsed.searchParams.get("platform") as PlatformId) || undefined, + ]; + const platforms = parsed.searchParams.get("platforms")?.split(",") as + | PlatformId[] + | undefined; + const sources = parsed.searchParams.get("sources")?.split(","); + const status = + (parsed.searchParams.get("status") as PostStatus) || undefined; + const stage = + (parsed.searchParams.get("stage") as SourceStage) || undefined; + + // read payload from PUT or POST + let payload = undefined as undefined | Buffer | string | object; + if (request.method === "POST" || request.method === "PUT") { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + const buffer = Buffer.concat(chunks); + payload = await parsePayload(buffer, request.headers["content-type"]); + } + + const args = { + password: password, + dryrun: dryrun || undefined, + model: model, + platforms: platforms, + platform: platform, + sources: sources, + source: source, + date: date ? new Date(date) : undefined, + status: status, + stage: stage, + payload: payload, + }; + + let code = 0; + let output = undefined; + let operatorid = undefined; + + let error = false as boolean | unknown; + try { + let user = undefined; + if (userid !== undefined) { + user = await User.getUser(userid); + } + const operator = await Server.getOperator(request, user); + operatorid = operator.id; + + output = await Fairpost.execute(operator, user, command, args); + code = 200; + Fairpost.logger.trace("Server.handleRequest", "success", request.url); + if (user !== undefined) { + await Server.addFairpostSession(response, user, command); + } + } catch (e) { + Fairpost.logger.error("Server.handleRequest", "error", request.url); + code = 500; + error = e; + output = {}; + } + + response.setHeader("Content-Type", "application/json"); + response.setHeader("Connection", "close"); + response.writeHead(code); + response.end( + JSON.stringify( + { + request: { + user: username, + operator: operatorid, + command: command, + arguments: args, + }, + result: output, + error: + error === false + ? false + : error instanceof Error + ? error.message + : JSON.stringify(error), + }, + JSONReplacer, + ), + ); + } + public static async getOperator(request: IncomingMessage, user?: User) { + if (user !== undefined) { + if (process.env.FAIRPOST_USER_AUTH === "fairpost") { + const cookies = cookie.parse(request.headers.cookie || ""); + if ("FairpostSession" in cookies) { + if ( + await AuthService.verifyToken( + user, + cookies["FairpostSession"] ?? "", + ) + ) { + return new Operator(user.id, ["user"], "api", true); + } + } + return new Operator(user.id, ["anonymous"], "api", false); + } + } + return new Operator("anonymous", ["anonymous"], "api", true); + } + + public static async addFairpostSession( + response: ServerResponse, + user: User, + command: string, + ) { + if (process.env.FAIRPOST_USER_AUTH === "fairpost") { + if (["login", "refresh-token"].includes(command)) { + const token = await AuthService.getToken(user); + response.setHeader( + "Set-Cookie", + cookie.serialize("FairpostSession", token, { + httpOnly: true, + secure: process.env.FAIRPOST_SESSION_SECURE !== "false", + sameSite: (process.env.FAIRPOST_SESSION_SAMESITE ?? "strict") as + | "strict" + | "lax" + | "none", + maxAge: +(process.env.FAIRPOST_SESSION_TIMEOUT ?? 60 * 60), + }), + ); + } + if (command === "logout") { + response.setHeader( + "Set-Cookie", + cookie.serialize("FairpostSession", "", { + httpOnly: true, + secure: process.env.FAIRPOST_SESSION_SECURE === "false", + sameSite: (process.env.FAIRPOST_SESSION_SAMESITE ?? "strict") as + | "strict" + | "lax" + | "none", + maxAge: +(process.env.FAIRPOST_SESSION_TIMEOUT ?? 60 * 60), + }), + ); + } + } + } +} diff --git a/src/types/CombinedResult.ts b/src/types/CombinedResult.ts new file mode 100644 index 0000000..2cf0964 --- /dev/null +++ b/src/types/CombinedResult.ts @@ -0,0 +1,15 @@ +/** + * A CombinedResult can be returned if you need to pass + * on failure without interupting a process, f.e. when + * you are operating on multiple subjects and some can + * fail, but you want to handle all. + * But in general, if you only plan to return one result, + * do that or throw an error and let the callee catch it. + */ + +export default interface CombinedResult { + success: boolean; + dryrun?: boolean; + message?: string; + result?: unknown; +} diff --git a/src/types/CommandArguments.ts b/src/types/CommandArguments.ts new file mode 100644 index 0000000..011d040 --- /dev/null +++ b/src/types/CommandArguments.ts @@ -0,0 +1,21 @@ +import { PlatformId } from "../platforms/index.ts"; +import { PostStatus, SourceStage } from "./index.ts"; + +/** + * CommandArguments are the arguments that can be passed + * to the Fairpost.execute method. + */ +export default interface CommandArguments { + dryrun?: boolean; + user?: string; + password?: string; + model?: string; + platforms?: PlatformId[]; + platform?: PlatformId; + sources?: string[]; + source?: string; + date?: Date; + status?: PostStatus; + stage?: SourceStage; + payload?: Buffer | string | object; +} diff --git a/src/types/Dto.ts b/src/types/Dto.ts new file mode 100644 index 0000000..e30e04c --- /dev/null +++ b/src/types/Dto.ts @@ -0,0 +1,8 @@ +import type FeedDto from "./FeedDto.ts"; +import type PlatformDto from "./FeedDto.ts"; +import type PostDto from "./FeedDto.ts"; +import type SourceDto from "./FeedDto.ts"; +import type UserDto from "./FeedDto.ts"; + +type Dto = FeedDto | PlatformDto | PostDto | SourceDto | UserDto; +export { type Dto as default }; diff --git a/src/types/FeedDto.ts b/src/types/FeedDto.ts new file mode 100644 index 0000000..550705b --- /dev/null +++ b/src/types/FeedDto.ts @@ -0,0 +1,7 @@ +export default interface FeedDto { + model: string; + id: string; + user_id: string; + path?: string; + sources?: string[]; +} diff --git a/src/types/FieldMapping.ts b/src/types/FieldMapping.ts new file mode 100644 index 0000000..c4c53e1 --- /dev/null +++ b/src/types/FieldMapping.ts @@ -0,0 +1,33 @@ +/** + * A FieldMapping on a model defines which fields + * are supposed to go in the Dto and which + * are restrictions may be imposed based on permissions + * on getting and setting the value. + */ + +export default interface FieldMapping { + [field: string]: { + type: + | "string" + | "string[]" + | "integer" + | "float" + | "boolean" + | "date" + | "json"; + label: string; + get: string[]; // (permissions | any | none)[] + set: string[]; // (permissions | any | none)[] + required?: boolean; // only if settable + default?: string | string[] | number | boolean | Date | object; + }; +} + +// after applying operator and user, get and set +// can be represented by simple booleans: +export interface ProcessedFieldMapping { + [field: string]: Omit & { + get: boolean; + set: boolean; + }; +} diff --git a/src/types/FileGroup.ts b/src/types/FileGroup.ts new file mode 100644 index 0000000..43b7c2c --- /dev/null +++ b/src/types/FileGroup.ts @@ -0,0 +1,8 @@ +enum FileGroup { + VIDEO = "video", + IMAGE = "image", + TEXT = "text", + OTHER = "other", +} + +export default FileGroup; diff --git a/src/types/FileInfo.ts b/src/types/FileInfo.ts new file mode 100644 index 0000000..68d2052 --- /dev/null +++ b/src/types/FileInfo.ts @@ -0,0 +1,13 @@ +import { FileGroup } from "./index.ts"; +export default interface FileInfo { + name: string; + original?: string; + basename: string; + extension: string; + group: FileGroup; + size: number; + mimetype: string; + order: number; + width?: number; + height?: number; +} diff --git a/src/types/PlatformDto.ts b/src/types/PlatformDto.ts new file mode 100644 index 0000000..3da61b4 --- /dev/null +++ b/src/types/PlatformDto.ts @@ -0,0 +1,8 @@ +export default interface PlatformDto { + model: string; + id: string; + user_id: string; + active?: boolean; + // more fields added by platform + [key: string]: string | string[] | number | boolean | undefined; +} diff --git a/src/types/PostDto.ts b/src/types/PostDto.ts new file mode 100644 index 0000000..d9f7fc5 --- /dev/null +++ b/src/types/PostDto.ts @@ -0,0 +1,22 @@ +import { type FileInfo, type PostStatus, type PostResult } from "./index.ts"; +export default interface PostDto { + model: string; + id: string; + user_id: string; + platform_id?: string; + source_id?: string; + valid?: boolean; + status?: PostStatus; + scheduled?: string; // date + published?: string; // date + title?: string; + body?: string; + tags?: string[]; + mentions?: string[]; + geo?: string; + files?: FileInfo[]; + ignore_files?: string[]; + results?: PostResult[]; + remote_id?: string; + link?: string; +} diff --git a/src/types/PostResult.ts b/src/types/PostResult.ts new file mode 100644 index 0000000..72f9c34 --- /dev/null +++ b/src/types/PostResult.ts @@ -0,0 +1,7 @@ +export default interface PostResult { + date: Date; + dryrun?: boolean; + error?: Error; + success: boolean; + response: object; +} diff --git a/src/types/PostStatus.ts b/src/types/PostStatus.ts new file mode 100644 index 0000000..9635618 --- /dev/null +++ b/src/types/PostStatus.ts @@ -0,0 +1,9 @@ +enum PostStatus { + UNKNOWN = "unknown", + UNSCHEDULED = "unscheduled", + SCHEDULED = "scheduled", + PUBLISHED = "published", + CANCELED = "canceled", + FAILED = "failed", +} +export default PostStatus; diff --git a/src/types/SourceDto.ts b/src/types/SourceDto.ts new file mode 100644 index 0000000..72a7957 --- /dev/null +++ b/src/types/SourceDto.ts @@ -0,0 +1,10 @@ +import { type FileInfo, type SourceStage } from "./index.ts"; +export default interface SourceDto { + model: string; + id: string; + user_id: string; + feed_id?: string; + stage?: SourceStage; + path?: string; + files?: FileInfo[]; +} diff --git a/src/types/SourceStage.ts b/src/types/SourceStage.ts new file mode 100644 index 0000000..a3e998d --- /dev/null +++ b/src/types/SourceStage.ts @@ -0,0 +1,9 @@ +enum SourceStage { + PENDING = "pending", // all posts unscheduled + ACTIVE = "active", // mixed post statuses + FINISHED = "finished", // all posts published or canceled + INCOMING = "incoming", // no posts yet + ARCHIVED = "archived", + UNKNOWN = "unknown", +} +export default SourceStage; diff --git a/src/types/UserDto.ts b/src/types/UserDto.ts new file mode 100644 index 0000000..712190c --- /dev/null +++ b/src/types/UserDto.ts @@ -0,0 +1,9 @@ +import { UserReport } from "./index.ts"; + +export default interface UserDto { + model: string; + id: string; + homedir?: string; + loglevel?: string; + report?: UserReport; +} diff --git a/src/types/UserReport.ts b/src/types/UserReport.ts new file mode 100644 index 0000000..ce24f8f --- /dev/null +++ b/src/types/UserReport.ts @@ -0,0 +1,23 @@ +import SourceStage from "./SourceStage"; +import PostStatus from "./PostStatus"; +import { PlatformId } from "../platforms"; +export default interface UserReport { + feed: { + count: { + [status in SourceStage]?: number; + }; + lastId: string; + nextId: string; + }; + platforms: { + [id in PlatformId]?: { + link: string; + count: { + [status in PostStatus]?: number; + }; + lastId: string; + nextId: string; + lastLink: string; + }; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..71e8bad --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,16 @@ +export type { default as CommandArguments } from "./CommandArguments.ts"; +export type { default as CombinedResult } from "./CombinedResult.ts"; +export type { default as FieldMapping } from "./FieldMapping.ts"; +export type { ProcessedFieldMapping } from "./FieldMapping.ts"; +export type { default as Dto } from "./Dto.ts"; +export type { default as FeedDto } from "./FeedDto.ts"; +export { default as FileGroup } from "./FileGroup.ts"; +export type { default as FileInfo } from "./FileInfo.ts"; +export type { default as PlatformDto } from "./PlatformDto.ts"; +export type { default as PostDto } from "./PostDto.ts"; +export type { default as PostResult } from "./PostResult.ts"; +export { default as PostStatus } from "./PostStatus.ts"; +export type { default as SourceDto } from "./SourceDto.ts"; +export { default as SourceStage } from "./SourceStage.ts"; +export type { default as UserDto } from "./UserDto.ts"; +export type { default as UserReport } from "./UserReport.ts"; diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..5251d49 --- /dev/null +++ b/src/utilities.ts @@ -0,0 +1,281 @@ +import User from "./models/User.ts"; +import crypto from "crypto"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function JSONReplacer(key: string, value: any): any { + if (value instanceof User) { + return undefined; + } + return value; +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isSimilarArray(a: any, b: any) { + a = Array.isArray(a) ? a : []; + b = Array.isArray(b) ? b : []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return a.length === b.length && a.every((el: any) => b.includes(el)); +} + +export async function parsePayload( + buffer: Buffer, + type?: string, +): Promise { + const str = buffer.toString("utf8"); + + const contentType = type?.split(";")[0].trim().toLowerCase(); + if (contentType === "application/json") { + try { + return JSON.parse(str); + } catch { + return buffer; // invalid JSON, keep raw + } + } + if (contentType && contentType.startsWith("text/")) { + return str; + } + + // Heuristic fallback: Try JSON first + try { + return JSON.parse(str); + } catch { + // Ignore, not valid JSON + } + + // Check for binary (NUL bytes or lots of control chars) + const isBinary = buffer.some((b) => b === 0 || b < 7 || (b > 13 && b < 32)); + + if (!isBinary) { + return str; + } + + // Otherwise, return raw binary + return buffer; +} + +export class ApiResponseError extends Error { + response: Response; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + responseData?: any; + responseText?: string; + constructor(response: Response, data?: object | string) { + super("ApiResponseError: " + response.status + " " + response.statusText); + this.response = response; + if (data && typeof data === "object") { + this.responseData = data; + } + if (data && typeof data === "string") { + this.responseText = data; + } + } +} + +export async function handleApiResponse(response: Response): Promise { + return await handleBlobResponse(response); +} + +export async function handleEmptyResponse( + response: Response, + includeHeaders = false, +): Promise { + const data = {} as { headers: { [key: string]: string } }; + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleJsonResponse( + response: Response, + includeHeaders = false, +): Promise { + if (!response.ok) { + // network error in the 3xx–5xx range + try { + const data = await response.json(); // may throw a syntaxerror + throw new ApiResponseError(response, data); + } catch { + throw new ApiResponseError(response); + } + } + const data = await response.json(); // may throw a syntaxerror + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + + return data; +} + +export async function handleTextResponse(response: Response): Promise { + const data = await response.text(); + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleBlobResponse(response: Response): Promise { + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response); + } + return await response.blob(); +} + +export async function handleArrayBufferResponse( + response: Response, +): Promise { + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response); + } + return await response.arrayBuffer(); +} + +export async function handleFormResponse( + response: Response, + includeHeaders = false, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = Object.fromEntries(await response.formData()) as any; + if (includeHeaders) { + data["headers"] = {}; + for (const [name, value] of response.headers) { + data["headers"][name] = value; + } + } + if (!response.ok) { + // network error in the 3xx–5xx range + throw new ApiResponseError(response, data); + } + return data; +} + +export async function handleApiError( + error: ApiResponseError, + user?: User, +): Promise { + if (!user) { + throw error; + } + let errorMessage = error.message; + + const errorDetails = {} as { [key: string]: string | number | object }; + + // details added by ApiResponseError + if (error.response) { + errorDetails["status"] = error.response.status; + errorDetails["statusText"] = error.response.statusText; + errorDetails["url"] = error.response.url; + } + if (error.responseData) { + errorDetails["data"] = JSON.stringify(error.responseData); + } + if (error.responseText) { + errorDetails["text"] = error.responseText; + } + + // errors thrown by fetch + // https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md + if (error.name === "AbortError") { + errorDetails["name"] = "AbortError"; + errorMessage += ": The request was Aborted"; + } + + if (error instanceof SyntaxError) { + // response.json() Unexpected token < in JSON + errorDetails["name"] = "SyntaxError"; + errorMessage += ": There was a SyntaxError in the response"; + } + + if (error.name === "FetchError") { + // codes added by node + errorDetails["name"] = "FetchError"; + if ("type" in error) { + errorDetails["type"] = error.type as number; + } + if ("code" in error) { + errorDetails["code"] = error.code as number; + } + if ("errno" in error) { + errorDetails["errno"] = error.errno as number; + } + } + throw user.log.error(errorMessage, error.response?.url, errorDetails); +} + +export async function encryptAESWeb(text: string, secret: string) { + const enc = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const keyMaterial = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + "PBKDF2", + false, + ["deriveKey"], + ); + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: iv, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"], + ); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + enc.encode(text), + ); + // Combine iv and encrypted data + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv, 0); + result.set(new Uint8Array(encrypted), iv.length); + return Buffer.from(result).toString("base64"); +} + +export async function decryptAESWeb(encryptedBase64: string, secret: string) { + const enc = new TextEncoder(); + const data = Buffer.from(encryptedBase64, "base64"); + const iv = data.subarray(0, 12); + const encrypted = data.subarray(12); + const keyMaterial = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + "PBKDF2", + false, + ["deriveKey"], + ); + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: iv, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + encrypted, + ); + return new TextDecoder().decode(decrypted); +} diff --git a/tsconfig.json b/tsconfig.json index d9339bf..d235b92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,18 @@ { "compilerOptions": { "target": "esnext", - "module": "commonjs", + "module": "esnext", + "moduleResolution": "node", + "noEmit": true, + "allowImportingTsExtensions": true, "sourceMap": true, - "outDir": "build" + "strict" : true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "build", + "skipLibCheck": true // see https://github.com/multiformats/js-multiformats/issues/327 }, "include": [ - "index.ts", - "fayrshare.ts" + "src/**/*" ] } \ No newline at end of file diff --git a/users/.gitkeep b/users/.gitkeep new file mode 100644 index 0000000..e69de29