├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .postman ├── api └── api_093b0c82-779f-419f-9aca-029910a07e65 ├── Dockerfile ├── LICENSE ├── README.md ├── docker └── entrypoint.sh ├── package-lock.json ├── package.json ├── postman └── collections │ ├── Communities.json │ └── People.json ├── src ├── cache.ts ├── config-manager.ts ├── database.ts ├── logger.ts ├── middleware │ ├── auth.ts │ └── client-header.ts ├── models │ ├── community.ts │ ├── content.ts │ ├── conversation.ts │ ├── endpoint.ts │ ├── notification.ts │ ├── post.ts │ ├── report.ts │ └── settings.ts ├── server.ts ├── services │ ├── api │ │ ├── index.ts │ │ └── routes │ │ │ ├── communities.ts │ │ │ ├── friend_messages.ts │ │ │ ├── people.ts │ │ │ ├── posts.ts │ │ │ ├── status.ts │ │ │ ├── topics.ts │ │ │ └── users.ts │ └── discovery │ │ ├── index.ts │ │ └── routes │ │ └── discovery.ts ├── types │ ├── common │ │ ├── config.ts │ │ ├── formatted-message.ts │ │ ├── param-pack.ts │ │ └── token.ts │ ├── express-subdomain.d.ts │ ├── express.d.ts │ ├── miiverse │ │ ├── community.ts │ │ ├── people.ts │ │ ├── post.ts │ │ ├── settings.ts │ │ └── wara-wara-plaza.ts │ ├── mongoose │ │ ├── community-posts-query.ts │ │ ├── community.ts │ │ ├── content.ts │ │ ├── conversation.ts │ │ ├── endpoint.ts │ │ ├── notification.ts │ │ ├── post-to-json-options.ts │ │ ├── post.ts │ │ ├── report.ts │ │ ├── settings.ts │ │ └── subcommunity-query.ts │ ├── node-snowflake.d.ts │ └── tga.d.ts └── util.ts ├── test └── test.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | config.json 4 | certs 5 | src/logs 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "globals": { 9 | "BigInt": true 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "require-atomic-updates": "warn", 20 | "no-case-declarations": "off", 21 | "no-empty": "off", 22 | "no-console": "off", 23 | "linebreak-style": "off", 24 | "no-global-assign": "off", 25 | "prefer-const": "error", 26 | "no-var": "error", 27 | "no-unused-vars": "off", 28 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 29 | "no-extra-semi": "off", 30 | "@typescript-eslint/no-extra-semi": "error", 31 | "@typescript-eslint/no-empty-interface": "warn", 32 | "@typescript-eslint/no-inferrable-types": "error", 33 | "@typescript-eslint/typedef": "error", 34 | "@typescript-eslint/explicit-function-return-type": "error", 35 | "keyword-spacing": "off", 36 | "@typescript-eslint/keyword-spacing": "error", 37 | "curly": "error", 38 | "brace-style": "error", 39 | "one-var": [ 40 | "error", 41 | "never" 42 | ], 43 | "indent": [ 44 | "error", 45 | "tab", 46 | { 47 | "SwitchCase": 1 48 | } 49 | ], 50 | "quotes": [ 51 | "error", 52 | "single" 53 | ], 54 | "semi": [ 55 | "error", 56 | "always" 57 | ] 58 | } 59 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # custom 133 | certs 134 | logs 135 | dist 136 | newman -------------------------------------------------------------------------------- /.postman/api: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY 2 | apis[] = {"apiId":"093b0c82-779f-419f-9aca-029910a07e65"} 3 | configVersion = 1.0.0 4 | type = api 5 | -------------------------------------------------------------------------------- /.postman/api_093b0c82-779f-419f-9aca-029910a07e65: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY 2 | configVersion = 1.0.0 3 | type = apiEntityData 4 | 5 | [config] 6 | id = 093b0c82-779f-419f-9aca-029910a07e65 7 | 8 | [config.relations] 9 | 10 | [config.relations.collections] 11 | rootDirectory = postman/collections 12 | files[] = {"id":"19511066-cb605935-6073-42c5-afa9-96d9a19c70a3","path":"Communities.json","metaData":{}} 13 | files[] = {"id":"19511066-2bb4ba1f-8e09-44b9-9ad1-5f59455c0ba7","path":"People.json","metaData":{}} 14 | 15 | [config.relations.collections.metaData] 16 | 17 | [config.relations.apiDefinition] 18 | rootDirectory = postman/schemas 19 | 20 | [config.relations.apiDefinition.metaData] 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN apk add --no-cache python3 make gcc g++ 4 | WORKDIR /app 5 | 6 | COPY "docker/entrypoint.sh" ./ 7 | 8 | COPY package*.json ./ 9 | RUN npm install 10 | 11 | COPY . ./ 12 | 13 | VOLUME [ "/app/config.json", "/app/certs" ] 14 | 15 | CMD ["sh", "entrypoint.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > ## DEPRECATED. NOW LIVES IN https://github.com/PretendoNetwork/juxtaposition 3 | 4 | # What is this? 5 | This is the Pretendo Network Miiverse API Server, which replaces the former Nintendo Network Miiverse API Server *.olv.nintendo.net 6 | # Install and usage 7 | First install [NodeJS](https://nodejs.org) and [MongoDB](https://mongodb.com). Download/clone this repo and run `npm i` to install all dependencies. Create a `config.json` to your liking (example in `config.example.json`). Run the server via `npm run start`. 8 | # To-Do 9 | - [x] Discovery Server 10 | - [x] Posts Server 11 | - [ ] Topic Server 12 | - [x] Communities Server 13 | - [ ] Integrate with PN account server 14 | # Currently implemented endpoints 15 | - [GET] https://discovery.olv.nintendo.net/v1/endpoint 16 | - [GET] https://api.olv.nintendo.net/v1/communities/0/posts 17 | - [GET] https://discovery.olv.nintendo.net/v1/people 18 | - [POST] https://api.olv.nintendo.net/v1/posts 19 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | files='config.json certs/access/private.pem certs/access/aes.key' 4 | 5 | for file in $files; do 6 | if [ ! -f $file ]; then 7 | echo "$PWD/$file file does not exist. Please mount and try again." 8 | exit 1 9 | fi 10 | done 11 | 12 | exec node src/server.js 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miiverse-api", 3 | "version": "2.0.0", 4 | "description": "Miiverse API Server", 5 | "main": "./dist/server.js", 6 | "scripts": { 7 | "lint": "npx eslint .", 8 | "build": "npm run lint && npm run clean && npx tsc && npx tsc-alias", 9 | "clean": "rimraf ./dist", 10 | "start": "node --enable-source-maps .", 11 | "test": "ts-node test/test.ts" 12 | }, 13 | "keywords": [], 14 | "author": "Pretendo Network", 15 | "contributors": [ 16 | { 17 | "name": "Jemma Poffinbarger", 18 | "email": "contact@jemsoftware.dev", 19 | "url": "https://jemsoftware.dev/" 20 | }, 21 | { 22 | "name": "Jonathan Barrow", 23 | "email": "jonbarrow1998@gmail.com", 24 | "url": "https://jonbarrow.dev/" 25 | } 26 | ], 27 | "license": "AGPL-3.0", 28 | "dependencies": { 29 | "@pretendonetwork/grpc": "^1.0.3", 30 | "aws-sdk": "^2.1204.0", 31 | "colors": "^1.4.0", 32 | "crc": "^4.3.2", 33 | "dotenv": "^16.0.3", 34 | "express": "^4.17.1", 35 | "express-subdomain": "^1.0.5", 36 | "fs-extra": "^9.0.0", 37 | "moment": "^2.24.0", 38 | "mongoose": "^6.10.1", 39 | "morgan": "^1.10.0", 40 | "multer": "^1.4.5-lts.1", 41 | "nice-grpc": "^2.0.0", 42 | "node-rsa": "^1.0.8", 43 | "node-snowflake": "0.0.1", 44 | "pako": "^1.0.11", 45 | "pngjs": "^5.0.0", 46 | "tga": "^1.0.3", 47 | "xmlbuilder": "^15.1.1", 48 | "zod": "^3.21.4" 49 | }, 50 | "devDependencies": { 51 | "@types/express": "^4.17.17", 52 | "@types/fs-extra": "^11.0.1", 53 | "@types/morgan": "^1.9.4", 54 | "@types/multer": "^1.4.7", 55 | "@types/newman": "^5.3.4", 56 | "@types/node-rsa": "^1.1.1", 57 | "@types/pako": "^2.0.0", 58 | "@types/pngjs": "^6.0.1", 59 | "@typescript-eslint/eslint-plugin": "^5.59.0", 60 | "@typescript-eslint/parser": "^5.59.0", 61 | "axios": "^1.3.6", 62 | "eslint": "^8.38.0", 63 | "newman": "^6.0.0", 64 | "ora": "^5.4.1", 65 | "postman-collection": "^4.1.7", 66 | "table": "^6.8.1", 67 | "ts-unused-exports": "^9.0.4", 68 | "tsc-alias": "^1.8.5", 69 | "typescript": "^5.0.4", 70 | "xmlbuilder2": "^3.1.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /postman/collections/Communities.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "cb605935-6073-42c5-afa9-96d9a19c70a3", 4 | "name": "Communities", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_uid": "19511066-cb605935-6073-42c5-afa9-96d9a19c70a3" 7 | }, 8 | "item": [ 9 | { 10 | "name": "GET /v1/communities", 11 | "item": [ 12 | { 13 | "name": "Sub-Community", 14 | "id": "0c9cf1fc-61a0-4deb-b910-e54701e39ae5", 15 | "protocolProfileBehavior": { 16 | "disableBodyPruning": true 17 | }, 18 | "request": { 19 | "method": "GET", 20 | "header": [ 21 | { 22 | "key": "X-Nintendo-ParamPack", 23 | "value": "{{PP_MarioVsDK}}", 24 | "type": "text" 25 | } 26 | ], 27 | "url": { 28 | "raw": "{{DOMAIN}}/v1/communities", 29 | "host": [ 30 | "{{DOMAIN}}" 31 | ], 32 | "path": [ 33 | "v1", 34 | "communities" 35 | ] 36 | } 37 | }, 38 | "response": [] 39 | }, 40 | { 41 | "name": "Single Community", 42 | "id": "77083b95-581f-40cf-8d83-ade183923ba0", 43 | "protocolProfileBehavior": { 44 | "disableBodyPruning": true 45 | }, 46 | "request": { 47 | "method": "GET", 48 | "header": [ 49 | { 50 | "key": "X-Nintendo-ParamPack", 51 | "value": "{{PP_Splatoon}}", 52 | "type": "text" 53 | } 54 | ], 55 | "url": { 56 | "raw": "{{DOMAIN}}/v1/communities", 57 | "host": [ 58 | "{{DOMAIN}}" 59 | ], 60 | "path": [ 61 | "v1", 62 | "communities" 63 | ], 64 | "query": [ 65 | { 66 | "key": "", 67 | "value": "", 68 | "type": "text", 69 | "disabled": true 70 | } 71 | ] 72 | } 73 | }, 74 | "response": [] 75 | }, 76 | { 77 | "name": "Invalid Title ID", 78 | "id": "9bb6971f-f95a-4a53-960a-e9383854c2cc", 79 | "protocolProfileBehavior": { 80 | "disableBodyPruning": true 81 | }, 82 | "request": { 83 | "method": "GET", 84 | "header": [ 85 | { 86 | "key": "X-Nintendo-ParamPack", 87 | "value": "{{PP_Bad_TID}}", 88 | "type": "text" 89 | } 90 | ], 91 | "url": { 92 | "raw": "{{DOMAIN}}/v1/communities", 93 | "host": [ 94 | "{{DOMAIN}}" 95 | ], 96 | "path": [ 97 | "v1", 98 | "communities" 99 | ], 100 | "query": [ 101 | { 102 | "key": "", 103 | "value": "", 104 | "type": "text", 105 | "disabled": true 106 | } 107 | ] 108 | } 109 | }, 110 | "response": [] 111 | }, 112 | { 113 | "name": "Invalid ParamPack Format", 114 | "id": "7e5058a3-5faa-4016-a86d-e288a1e3355f", 115 | "protocolProfileBehavior": { 116 | "disableBodyPruning": true 117 | }, 118 | "request": { 119 | "method": "GET", 120 | "header": [ 121 | { 122 | "key": "X-Nintendo-ParamPack", 123 | "value": "{{PP_Bad Format}}", 124 | "type": "text" 125 | } 126 | ], 127 | "url": { 128 | "raw": "{{DOMAIN}}/v1/communities", 129 | "host": [ 130 | "{{DOMAIN}}" 131 | ], 132 | "path": [ 133 | "v1", 134 | "communities" 135 | ], 136 | "query": [ 137 | { 138 | "key": "", 139 | "value": "", 140 | "type": "text", 141 | "disabled": true 142 | } 143 | ] 144 | } 145 | }, 146 | "response": [] 147 | } 148 | ], 149 | "id": "9a31cc53-451e-4fb2-aace-ee3db9641243" 150 | }, 151 | { 152 | "name": "GET /v1/communities/:id/posts", 153 | "item": [ 154 | { 155 | "name": "No Params", 156 | "id": "3ea312f0-d205-4c25-b11a-218d0b28854d", 157 | "protocolProfileBehavior": { 158 | "disableBodyPruning": true 159 | }, 160 | "request": { 161 | "method": "GET", 162 | "header": [ 163 | { 164 | "key": "X-Nintendo-ParamPack", 165 | "value": "{{PP_Splatoon}}", 166 | "type": "text" 167 | } 168 | ], 169 | "url": { 170 | "raw": "{{DOMAIN}}/v1/communities/0/posts", 171 | "host": [ 172 | "{{DOMAIN}}" 173 | ], 174 | "path": [ 175 | "v1", 176 | "communities", 177 | "0", 178 | "posts" 179 | ] 180 | } 181 | }, 182 | "response": [] 183 | }, 184 | { 185 | "name": "Limit", 186 | "id": "84d199be-78f5-4d65-955b-08f47b7ed1e7", 187 | "protocolProfileBehavior": { 188 | "disableBodyPruning": true 189 | }, 190 | "request": { 191 | "method": "GET", 192 | "header": [ 193 | { 194 | "key": "X-Nintendo-ParamPack", 195 | "value": "{{PP_Splatoon}}", 196 | "type": "text" 197 | } 198 | ], 199 | "url": { 200 | "raw": "{{DOMAIN}}/v1/communities/0/posts?limit=2", 201 | "host": [ 202 | "{{DOMAIN}}" 203 | ], 204 | "path": [ 205 | "v1", 206 | "communities", 207 | "0", 208 | "posts" 209 | ], 210 | "query": [ 211 | { 212 | "key": "limit", 213 | "value": "2" 214 | } 215 | ] 216 | } 217 | }, 218 | "response": [] 219 | }, 220 | { 221 | "name": "Search Key", 222 | "id": "3754a070-9dbc-4b35-b0b8-4480d5d5aa38", 223 | "protocolProfileBehavior": { 224 | "disableBodyPruning": true 225 | }, 226 | "request": { 227 | "method": "GET", 228 | "header": [ 229 | { 230 | "key": "X-Nintendo-ParamPack", 231 | "value": "{{PP_ACPlaza}}", 232 | "type": "text" 233 | } 234 | ], 235 | "url": { 236 | "raw": "{{DOMAIN}}/v1/communities/0/posts?search_key=sza", 237 | "host": [ 238 | "{{DOMAIN}}" 239 | ], 240 | "path": [ 241 | "v1", 242 | "communities", 243 | "0", 244 | "posts" 245 | ], 246 | "query": [ 247 | { 248 | "key": "search_key", 249 | "value": "sza" 250 | } 251 | ] 252 | } 253 | }, 254 | "response": [] 255 | }, 256 | { 257 | "name": "Type memo", 258 | "id": "71362a5d-d976-402d-80d5-80e593d6ac0f", 259 | "protocolProfileBehavior": { 260 | "disableBodyPruning": true 261 | }, 262 | "request": { 263 | "method": "GET", 264 | "header": [ 265 | { 266 | "key": "X-Nintendo-ParamPack", 267 | "value": "{{PP_ACPlaza}}", 268 | "type": "text" 269 | } 270 | ], 271 | "url": { 272 | "raw": "{{DOMAIN}}/v1/communities/0/posts?type=memo", 273 | "host": [ 274 | "{{DOMAIN}}" 275 | ], 276 | "path": [ 277 | "v1", 278 | "communities", 279 | "0", 280 | "posts" 281 | ], 282 | "query": [ 283 | { 284 | "key": "type", 285 | "value": "memo" 286 | } 287 | ] 288 | } 289 | }, 290 | "response": [] 291 | }, 292 | { 293 | "name": "By Followings", 294 | "id": "70355c69-a29f-4fbc-bdb4-495b579b21f2", 295 | "protocolProfileBehavior": { 296 | "disableBodyPruning": true 297 | }, 298 | "request": { 299 | "method": "GET", 300 | "header": [ 301 | { 302 | "key": "X-Nintendo-ParamPack", 303 | "value": "{{PP_ACPlaza}}", 304 | "type": "text" 305 | } 306 | ], 307 | "url": { 308 | "raw": "{{DOMAIN}}/v1/communities/0/posts?by=followings", 309 | "host": [ 310 | "{{DOMAIN}}" 311 | ], 312 | "path": [ 313 | "v1", 314 | "communities", 315 | "0", 316 | "posts" 317 | ], 318 | "query": [ 319 | { 320 | "key": "by", 321 | "value": "followings" 322 | } 323 | ] 324 | } 325 | }, 326 | "response": [] 327 | }, 328 | { 329 | "name": "By Self", 330 | "id": "823b727d-f295-4cb3-9dbc-12c55a716ea1", 331 | "protocolProfileBehavior": { 332 | "disableBodyPruning": true 333 | }, 334 | "request": { 335 | "method": "GET", 336 | "header": [ 337 | { 338 | "key": "X-Nintendo-ParamPack", 339 | "value": "{{PP_Splatoon}}", 340 | "type": "text" 341 | } 342 | ], 343 | "url": { 344 | "raw": "{{DOMAIN}}/v1/communities/0/posts?by=self", 345 | "host": [ 346 | "{{DOMAIN}}" 347 | ], 348 | "path": [ 349 | "v1", 350 | "communities", 351 | "0", 352 | "posts" 353 | ], 354 | "query": [ 355 | { 356 | "key": "by", 357 | "value": "self" 358 | } 359 | ] 360 | } 361 | }, 362 | "response": [] 363 | }, 364 | { 365 | "name": "Allow Spoiler", 366 | "id": "875cd973-3de9-429c-b61b-ba80320ffd22", 367 | "protocolProfileBehavior": { 368 | "disableBodyPruning": true 369 | }, 370 | "request": { 371 | "method": "GET", 372 | "header": [ 373 | { 374 | "key": "X-Nintendo-ParamPack", 375 | "value": "{{PP_Splatoon}}", 376 | "type": "text" 377 | } 378 | ], 379 | "url": { 380 | "raw": "{{DOMAIN}}/v1/communities/0/posts?allow_spoiler=1", 381 | "host": [ 382 | "{{DOMAIN}}" 383 | ], 384 | "path": [ 385 | "v1", 386 | "communities", 387 | "0", 388 | "posts" 389 | ], 390 | "query": [ 391 | { 392 | "key": "allow_spoiler", 393 | "value": "1" 394 | } 395 | ] 396 | } 397 | }, 398 | "response": [] 399 | } 400 | ], 401 | "id": "9078f117-f05e-459b-ac03-59be51f48ecf" 402 | } 403 | ], 404 | "auth": { 405 | "type": "apikey", 406 | "apikey": [ 407 | { 408 | "key": "value", 409 | "value": "{{ServiceToken}}", 410 | "type": "string" 411 | }, 412 | { 413 | "key": "key", 414 | "value": "X-Nintendo-ServiceToken", 415 | "type": "string" 416 | } 417 | ] 418 | }, 419 | "event": [ 420 | { 421 | "listen": "prerequest", 422 | "script": { 423 | "id": "64933ef4-dc38-4d6f-a8c5-54b93decd369", 424 | "type": "text/javascript", 425 | "exec": [ 426 | "" 427 | ] 428 | } 429 | }, 430 | { 431 | "listen": "test", 432 | "script": { 433 | "id": "f00757f9-3faf-40da-89ff-0f3fb356678f", 434 | "type": "text/javascript", 435 | "exec": [ 436 | "const headerSchema = {", 437 | " \"type\": \"object\",", 438 | " \"properties\": {", 439 | " \"result\": {", 440 | " \"type\": \"object\",", 441 | " \"properties\": {", 442 | " \"has_error\": {", 443 | " \"type\": \"string\",", 444 | " \"maxLength\": 1", 445 | " },", 446 | " \"version\": {", 447 | " \"type\": \"string\",", 448 | " \"maxLength\": 1", 449 | " },", 450 | " \"expire\": {", 451 | " \"type\": \"string\",", 452 | " \"pattern\": \"^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?\"", 453 | " },", 454 | " \"request_name\": {", 455 | " \"type\": \"string\"", 456 | " }", 457 | " },", 458 | " \"required\": [", 459 | " \"has_error\",", 460 | " \"version\",", 461 | " \"request_name\"", 462 | " ]", 463 | " },", 464 | "", 465 | " },", 466 | " \"required\": [", 467 | " \"result\"", 468 | " ]", 469 | "};", 470 | "", 471 | "const errorSchema = {", 472 | " \"type\": \"object\",", 473 | " \"properties\": {", 474 | " \"result\": {", 475 | " \"type\": \"object\",", 476 | " \"properties\": {", 477 | " \"has_error\": {", 478 | " \"type\": \"string\",", 479 | " \"maxLength\": 1", 480 | " },", 481 | " \"version\": {", 482 | " \"type\": \"string\",", 483 | " \"maxLength\": 1", 484 | " },", 485 | " \"code\": {", 486 | " \"type\": \"string\",", 487 | " \"maxLength\": 3", 488 | " },", 489 | " \"error_code\": {", 490 | " \"type\": \"string\",", 491 | " \"maxLength\": 4", 492 | " },", 493 | " \"message\": {", 494 | " \"type\": \"string\"", 495 | " }", 496 | " },", 497 | " \"required\": [", 498 | " \"has_error\",", 499 | " \"version\",", 500 | " \"code\",", 501 | " \"error_code\",", 502 | " \"message\"", 503 | " ]", 504 | " },", 505 | "", 506 | " },", 507 | " \"required\": [", 508 | " \"result\"", 509 | " ]", 510 | "};", 511 | "", 512 | "pm.test(\"Valid XML Response Header\", function () {", 513 | " var json = xml2Json(pm.response.text());", 514 | " console.log(pm.response.code);", 515 | " if(pm.response.code === 200)", 516 | " pm.expect(json).to.have.jsonSchema(headerSchema);", 517 | " else ", 518 | " pm.expect(json).to.have.jsonSchema(errorSchema);", 519 | " ", 520 | "})" 521 | ] 522 | } 523 | } 524 | ] 525 | } -------------------------------------------------------------------------------- /postman/collections/People.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "2bb4ba1f-8e09-44b9-9ad1-5f59455c0ba7", 4 | "name": "People", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_uid": "19511066-2bb4ba1f-8e09-44b9-9ad1-5f59455c0ba7" 7 | }, 8 | "item": [ 9 | { 10 | "name": "GET /v1/people", 11 | "item": [ 12 | { 13 | "name": "Friends", 14 | "id": "688f1727-636a-4a41-ba96-a9f52c3fe2b9", 15 | "protocolProfileBehavior": { 16 | "disableBodyPruning": true 17 | }, 18 | "request": { 19 | "method": "GET", 20 | "header": [ 21 | { 22 | "key": "X-Nintendo-ParamPack", 23 | "value": "{{PP_Splatoon}}", 24 | "type": "text" 25 | } 26 | ], 27 | "url": { 28 | "raw": "{{DOMAIN}}/v1/people?relation=friend&distinct_pid=1", 29 | "host": [ 30 | "{{DOMAIN}}" 31 | ], 32 | "path": [ 33 | "v1", 34 | "people" 35 | ], 36 | "query": [ 37 | { 38 | "key": "relation", 39 | "value": "friend" 40 | }, 41 | { 42 | "key": "distinct_pid", 43 | "value": "1" 44 | } 45 | ] 46 | } 47 | }, 48 | "response": [] 49 | }, 50 | { 51 | "name": "Following", 52 | "id": "00b11bd6-25f1-4c16-ac6f-d8f17895ca1c", 53 | "protocolProfileBehavior": { 54 | "disableBodyPruning": true 55 | }, 56 | "request": { 57 | "method": "GET", 58 | "header": [ 59 | { 60 | "key": "X-Nintendo-ParamPack", 61 | "value": "{{PP_Splatoon}}", 62 | "type": "text" 63 | } 64 | ], 65 | "url": { 66 | "raw": "{{DOMAIN}}/v1/people?relation=following&distinct_pid=1", 67 | "host": [ 68 | "{{DOMAIN}}" 69 | ], 70 | "path": [ 71 | "v1", 72 | "people" 73 | ], 74 | "query": [ 75 | { 76 | "key": "relation", 77 | "value": "following" 78 | }, 79 | { 80 | "key": "distinct_pid", 81 | "value": "1" 82 | } 83 | ] 84 | } 85 | }, 86 | "response": [] 87 | } 88 | ], 89 | "id": "826dbc90-90a0-483c-a94f-5d93f48a1804" 90 | } 91 | ], 92 | "auth": { 93 | "type": "apikey", 94 | "apikey": [ 95 | { 96 | "key": "value", 97 | "value": "{{ServiceToken}}", 98 | "type": "string" 99 | }, 100 | { 101 | "key": "key", 102 | "value": "X-Nintendo-ServiceToken", 103 | "type": "string" 104 | } 105 | ] 106 | }, 107 | "event": [ 108 | { 109 | "listen": "prerequest", 110 | "script": { 111 | "id": "86097a74-4b68-4b97-b176-45bcbedd2abf", 112 | "type": "text/javascript", 113 | "exec": [ 114 | "" 115 | ] 116 | } 117 | }, 118 | { 119 | "listen": "test", 120 | "script": { 121 | "id": "540f44d8-e877-4739-ad86-0e471f183a27", 122 | "type": "text/javascript", 123 | "exec": [ 124 | "const headerSchema = {", 125 | " \"type\": \"object\",", 126 | " \"properties\": {", 127 | " \"result\": {", 128 | " \"type\": \"object\",", 129 | " \"properties\": {", 130 | " \"has_error\": {", 131 | " \"type\": \"string\",", 132 | " \"maxLength\": 1", 133 | " },", 134 | " \"version\": {", 135 | " \"type\": \"string\",", 136 | " \"maxLength\": 1", 137 | " },", 138 | " \"expire\": {", 139 | " \"type\": \"string\",", 140 | " \"pattern\": \"^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?\"", 141 | " },", 142 | " \"request_name\": {", 143 | " \"type\": \"string\"", 144 | " }", 145 | " },", 146 | " \"required\": [", 147 | " \"has_error\",", 148 | " \"version\",", 149 | " \"request_name\"", 150 | " ]", 151 | " },", 152 | "", 153 | " },", 154 | " \"required\": [", 155 | " \"result\"", 156 | " ]", 157 | "};", 158 | "", 159 | "pm.test(\"Valid XML Response Header\", function () {", 160 | " var json = xml2Json(pm.response.text());", 161 | " pm.expect(json).to.have.jsonSchema(headerSchema);", 162 | "})" 163 | ] 164 | } 165 | } 166 | ] 167 | } -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | export default class Cache { 2 | private data?: T; 3 | private expireAt: number; 4 | private cacheTime: number; 5 | 6 | constructor(cacheTime: number) { 7 | this.expireAt = Date.now() + cacheTime; 8 | this.cacheTime = cacheTime; 9 | } 10 | 11 | valid(): boolean { 12 | if (!this.data || Date.now() >= this.expireAt) { 13 | return false; 14 | } 15 | 16 | return true; 17 | } 18 | 19 | update(data: T): void { 20 | this.expireAt = Date.now() + this.cacheTime; 21 | this.data = data; 22 | } 23 | 24 | get(): T | undefined { 25 | return this.data; 26 | } 27 | } -------------------------------------------------------------------------------- /src/config-manager.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import mongoose from 'mongoose'; 3 | import dotenv from 'dotenv'; 4 | import { LOG_INFO, LOG_WARN, LOG_ERROR } from '@/logger'; 5 | import { Config } from '@/types/common/config'; 6 | 7 | dotenv.config(); 8 | 9 | LOG_INFO('Loading config'); 10 | 11 | let mongooseConnectOptionsMain: mongoose.ConnectOptions = {}; 12 | 13 | if (process.env.PN_MIIVERSE_API_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH) { 14 | mongooseConnectOptionsMain = fs.readJSONSync(process.env.PN_MIIVERSE_API_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH); 15 | } else { 16 | LOG_WARN('No Mongoose connection options found for main connection. To add connection options, set PN_MIIVERSE_API_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH to the path of your options JSON file'); 17 | } 18 | 19 | export const config: Config = { 20 | http: { 21 | port: Number(process.env.PN_MIIVERSE_API_CONFIG_HTTP_PORT || '') 22 | }, 23 | account_server_address: process.env.PN_MIIVERSE_API_CONFIG_ACCOUNT_SERVER_ADDRESS || '', 24 | mongoose: { 25 | connection_string: process.env.PN_MIIVERSE_API_CONFIG_MONGO_CONNECTION_STRING || '', 26 | options: mongooseConnectOptionsMain 27 | }, 28 | s3: { 29 | endpoint: process.env.PN_MIIVERSE_API_CONFIG_S3_ENDPOINT || '', 30 | key: process.env.PN_MIIVERSE_API_CONFIG_S3_ACCESS_KEY || '', 31 | secret: process.env.PN_MIIVERSE_API_CONFIG_S3_ACCESS_SECRET || '' 32 | }, 33 | grpc: { 34 | friends: { 35 | ip: process.env.PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_IP || '', 36 | port: Number(process.env.PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_PORT || ''), 37 | api_key: process.env.PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_API_KEY || '' 38 | }, 39 | account: { 40 | ip: process.env.PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_IP || '', 41 | port: Number(process.env.PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_PORT || ''), 42 | api_key: process.env.PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_API_KEY || '' 43 | } 44 | }, 45 | aes_key: process.env.PN_MIIVERSE_API_CONFIG_AES_KEY || '' 46 | }; 47 | 48 | LOG_INFO('Config loaded, checking integrity'); 49 | 50 | if (!config.http.port) { 51 | LOG_ERROR('Failed to find HTTP port. Set the PN_MIIVERSE_API_CONFIG_HTTP_PORT environment variable'); 52 | process.exit(0); 53 | } 54 | 55 | if (!config.account_server_address) { 56 | LOG_ERROR('Failed to find account server address. Set the PN_MIIVERSE_API_CONFIG_ACCOUNT_SERVER_ADDRESS environment variable'); 57 | process.exit(0); 58 | } 59 | 60 | if (!config.mongoose.connection_string) { 61 | LOG_ERROR('Failed to find MongoDB connection string. Set the PN_MIIVERSE_API_CONFIG_MONGO_CONNECTION_STRING environment variable'); 62 | process.exit(0); 63 | } 64 | 65 | if (!config.s3.endpoint) { 66 | LOG_ERROR('Failed to find s3 endpoint. Set the PN_MIIVERSE_API_CONFIG_S3_ENDPOINT environment variable'); 67 | process.exit(0); 68 | } 69 | 70 | if (!config.s3.key) { 71 | LOG_ERROR('Failed to find s3 key. Set the PN_MIIVERSE_API_CONFIG_S3_ACCESS_KEY environment variable'); 72 | process.exit(0); 73 | } 74 | 75 | if (!config.s3.secret) { 76 | LOG_ERROR('Failed to find s3 secret. Set the PN_MIIVERSE_API_CONFIG_S3_ACCESS_SECRET environment variable'); 77 | process.exit(0); 78 | } 79 | 80 | if (!config.grpc.friends.ip) { 81 | LOG_ERROR('Failed to find NEX Friends gRPC ip. Set the PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_IP environment variable'); 82 | process.exit(0); 83 | } 84 | 85 | if (!config.grpc.friends.port) { 86 | LOG_ERROR('Failed to find NEX Friends gRPC port. Set the PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_PORT environment variable'); 87 | process.exit(0); 88 | } 89 | 90 | if (!config.grpc.friends.api_key) { 91 | LOG_ERROR('Failed to find NEX Friends gRPC API key. Set the PN_MIIVERSE_API_CONFIG_GRPC_FRIENDS_API_KEY environment variable'); 92 | process.exit(0); 93 | } 94 | 95 | if (!config.grpc.account.ip) { 96 | LOG_ERROR('Failed to find account server gRPC ip. Set the PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_IP environment variable'); 97 | process.exit(0); 98 | } 99 | 100 | if (!config.grpc.account.port) { 101 | LOG_ERROR('Failed to find account server gRPC port. Set the PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_PORT environment variable'); 102 | process.exit(0); 103 | } 104 | 105 | if (!config.grpc.account.api_key) { 106 | LOG_ERROR('Failed to find account server gRPC API key. Set the PN_MIIVERSE_API_CONFIG_GRPC_ACCOUNT_API_KEY environment variable'); 107 | process.exit(0); 108 | } 109 | 110 | if (!config.aes_key) { 111 | LOG_ERROR('Token AES key is not set. Set the PN_MIIVERSE_API_CONFIG_AES_KEY environment variable to your AES-256-CBC key'); 112 | process.exit(0); 113 | } -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { LOG_INFO } from '@/logger'; 3 | import { Community } from '@/models/community'; 4 | import { Content } from '@/models/content'; 5 | import { Conversation } from '@/models/conversation'; 6 | import { Endpoint } from '@/models/endpoint'; 7 | import { Post } from '@/models/post'; 8 | import { Settings } from '@/models/settings'; 9 | import { config } from '@/config-manager'; 10 | import { HydratedCommunityDocument } from '@/types/mongoose/community'; 11 | import { HydratedPostDocument, IPost } from '@/types/mongoose/post'; 12 | import { HydratedEndpointDocument } from '@/types/mongoose/endpoint'; 13 | import { HydratedSettingsDocument } from '@/types/mongoose/settings'; 14 | import { HydratedContentDocument } from '@/types/mongoose/content'; 15 | import { HydratedConversationDocument } from '@/types/mongoose/conversation'; 16 | 17 | const { mongoose: mongooseConfig } = config; 18 | 19 | let connection: mongoose.Connection; 20 | 21 | export async function connect(): Promise { 22 | await mongoose.connect(mongooseConfig.connection_string, mongooseConfig.options); 23 | 24 | connection = mongoose.connection; 25 | connection.on('connected', () => { 26 | LOG_INFO('MongoDB connected'); 27 | }); 28 | connection.on('error', console.error.bind(console, 'connection error:')); 29 | connection.on('close', () => { 30 | connection.removeAllListeners(); 31 | }); 32 | } 33 | 34 | function verifyConnected(): void { 35 | if (!connection) { 36 | connect(); 37 | } 38 | } 39 | 40 | export async function getMostPopularCommunities(limit: number): Promise { 41 | verifyConnected(); 42 | 43 | return Community.find({ parent: null, type: 0 }).sort({ followers: -1 }).limit(limit); 44 | } 45 | 46 | export async function getNewCommunities(limit: number): Promise { 47 | verifyConnected(); 48 | 49 | return Community.find({ parent: null, type: 0 }).sort([['created_at', -1]]).limit(limit); 50 | } 51 | 52 | export async function getSubCommunities(parentCommunityID: string): Promise { 53 | verifyConnected(); 54 | 55 | return Community.find({ 56 | parent: parentCommunityID 57 | }); 58 | } 59 | 60 | export async function getCommunityByTitleID(titleID: string): Promise { 61 | verifyConnected(); 62 | 63 | return Community.findOne({ 64 | title_id: titleID 65 | }); 66 | } 67 | 68 | export async function getCommunityByTitleIDs(titleIDs: string[]): Promise { 69 | verifyConnected(); 70 | 71 | return Community.findOne({ 72 | title_id: { $in: titleIDs } 73 | }); 74 | } 75 | 76 | export async function getCommunityByID(communityID: string): Promise { 77 | verifyConnected(); 78 | 79 | return Community.findOne({ 80 | community_id: communityID 81 | }); 82 | } 83 | 84 | export async function getPostByID(postID: string): Promise { 85 | verifyConnected(); 86 | 87 | return Post.findOne({ 88 | id: postID 89 | }); 90 | } 91 | 92 | export async function getPostReplies(postID: string, limit: number): Promise { 93 | verifyConnected(); 94 | 95 | return Post.find({ 96 | parent: postID, 97 | removed: false, 98 | app_data: { $ne: null } 99 | }).limit(limit); 100 | } 101 | 102 | export async function getDuplicatePosts(pid: number, post: IPost): Promise { 103 | verifyConnected(); 104 | 105 | return Post.findOne({ 106 | pid: pid, 107 | body: post.body, 108 | painting: post.painting, 109 | screenshot: post.screenshot, 110 | parent: null, 111 | removed: false 112 | }); 113 | } 114 | 115 | export async function getPostsBytitleID(titleID: string[], limit: number): Promise { 116 | verifyConnected(); 117 | 118 | return Post.find({ 119 | title_id: titleID, 120 | parent: null, 121 | removed: false 122 | }).sort({ created_at: -1 }).limit(limit); 123 | } 124 | 125 | export async function getEndpoints(): Promise { 126 | verifyConnected(); 127 | 128 | return Endpoint.find({}); 129 | } 130 | 131 | export async function getEndpoint(accessLevel: string): Promise { 132 | verifyConnected(); 133 | 134 | return Endpoint.findOne({ 135 | server_access_level: accessLevel 136 | }); 137 | } 138 | 139 | export async function getUserSettings(pid: number): Promise { 140 | verifyConnected(); 141 | 142 | return Settings.findOne({ pid: pid }); 143 | } 144 | 145 | export async function getUserContent(pid: number): Promise { 146 | verifyConnected(); 147 | 148 | return Content.findOne({ pid: pid }); 149 | } 150 | 151 | export async function getFollowedUsers(content: HydratedContentDocument): Promise { 152 | verifyConnected(); 153 | 154 | return Settings.find({ 155 | pid: content.followed_users 156 | }); 157 | } 158 | 159 | export async function getConversationByUsers(pids: number[]): Promise { 160 | verifyConnected(); 161 | 162 | return Conversation.findOne({ 163 | $and: [ 164 | { 'users.pid': pids[0] }, 165 | { 'users.pid': pids[1] } 166 | ] 167 | }); 168 | } 169 | 170 | export async function getFriendMessages(pid: string, search_key: string[], limit: number): Promise { 171 | verifyConnected(); 172 | 173 | return Post.find({ 174 | message_to_pid: pid, 175 | search_key: search_key, 176 | parent: null, 177 | removed: false 178 | }).sort({ created_at: 1 }).limit(limit); 179 | } -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import colors from 'colors'; 3 | 4 | colors.enable(); 5 | 6 | const root = process.env.PN_MIIVERSE_API_LOGGER_PATH ? process.env.PN_MIIVERSE_API_LOGGER_PATH : `${__dirname}/..`; 7 | fs.ensureDirSync(`${root}/logs`); 8 | 9 | const streams = { 10 | latest: fs.createWriteStream(`${root}/logs/latest.log`), 11 | success: fs.createWriteStream(`${root}/logs/success.log`), 12 | error: fs.createWriteStream(`${root}/logs/error.log`), 13 | warn: fs.createWriteStream(`${root}/logs/warn.log`), 14 | info: fs.createWriteStream(`${root}/logs/info.log`) 15 | } as const; 16 | 17 | export function LOG_SUCCESS(input: string): void { 18 | const time = new Date(); 19 | input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [SUCCESS]: ${input}`; 20 | streams.success.write(`${input}\n`); 21 | 22 | console.log(`${input}`.green.bold); 23 | } 24 | 25 | export function LOG_ERROR(input: string): void { 26 | const time = new Date(); 27 | input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [ERROR]: ${input}`; 28 | streams.error.write(`${input}\n`); 29 | 30 | console.log(`${input}`.red.bold); 31 | } 32 | 33 | export function LOG_WARN(input: string): void { 34 | const time = new Date(); 35 | input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [WARN]: ${input}`; 36 | streams.warn.write(`${input}\n`); 37 | 38 | console.log(`${input}`.yellow.bold); 39 | } 40 | 41 | export function LOG_INFO(input: string): void { 42 | const time = new Date(); 43 | input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [INFO]: ${input}`; 44 | streams.info.write(`${input}\n`); 45 | 46 | console.log(`${input}`.cyan.bold); 47 | } -------------------------------------------------------------------------------- /src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import { z } from 'zod'; 4 | import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; 5 | import { getEndpoint } from '@/database'; 6 | import { getUserAccountData, getValueFromHeaders, decodeParamPack, getPIDFromServiceToken } from '@/util'; 7 | import { HydratedEndpointDocument } from '@/types/mongoose/endpoint'; 8 | 9 | const ParamPackSchema = z.object({ 10 | title_id: z.string(), 11 | access_key: z.string(), 12 | platform_id: z.string(), 13 | region_id: z.string(), 14 | language_id: z.string(), 15 | country_id: z.string(), 16 | area_id: z.string(), 17 | network_restriction: z.string(), 18 | friend_restriction: z.string(), 19 | rating_restriction: z.string(), 20 | rating_organization: z.string(), 21 | transferable_id: z.string(), 22 | tz_name: z.string(), 23 | utc_offset: z.string(), 24 | remaster_version: z.string().optional() 25 | }); 26 | 27 | async function auth(request: express.Request, response: express.Response, next: express.NextFunction): Promise { 28 | if (request.path.includes('/v1/status')) { 29 | return next(); 30 | } 31 | 32 | // * Just don't care about the token here 33 | if (request.path === '/v1/topics') { 34 | return next(); 35 | } 36 | 37 | let encryptedToken = getValueFromHeaders(request.headers, 'x-nintendo-servicetoken'); 38 | if (!encryptedToken) { 39 | encryptedToken = getValueFromHeaders(request.headers, 'olive service token'); 40 | } 41 | 42 | if (!encryptedToken) { 43 | return badAuth(response, 15, 'NO_TOKEN'); 44 | } 45 | 46 | const pid: number = getPIDFromServiceToken(encryptedToken); 47 | if (pid === 0) { 48 | return badAuth(response, 16, 'BAD_TOKEN'); 49 | } 50 | 51 | const paramPack = getValueFromHeaders(request.headers, 'x-nintendo-parampack'); 52 | if (!paramPack) { 53 | return badAuth(response, 17, 'NO_PARAM'); 54 | } 55 | 56 | const paramPackData = decodeParamPack(paramPack); 57 | const paramPackCheck = ParamPackSchema.safeParse(paramPackData); 58 | if (!paramPackCheck.success) { 59 | console.log(paramPackCheck.error); 60 | return badAuth(response, 18, 'BAD_PARAM'); 61 | } 62 | 63 | let user: GetUserDataResponse; 64 | 65 | try { 66 | user = await getUserAccountData(pid); 67 | } catch (error) { 68 | // TODO - Log this error 69 | console.log(error); 70 | return badAuth(response, 18, 'BAD_PARAM'); 71 | } 72 | 73 | let discovery: HydratedEndpointDocument | null; 74 | 75 | if (user) { 76 | discovery = await getEndpoint(user.serverAccessLevel); 77 | } else { 78 | discovery = await getEndpoint('prod'); 79 | } 80 | 81 | if (!discovery) { 82 | return badAuth(response, 19, 'NO_DISCOVERY'); 83 | } 84 | 85 | if (discovery.status !== 0) { 86 | return serverError(response, discovery); 87 | } 88 | 89 | // TODO - This is temp, testing something. Will be removed in the future 90 | if (request.path !== '/v1/endpoint') { 91 | if (user.serverAccessLevel !== 'test' && user.serverAccessLevel !== 'dev') { 92 | return badAuth(response, 16, 'BAD_TOKEN'); 93 | } 94 | } 95 | 96 | // * This is a false positive from ESLint. 97 | // * Since this middleware is only ever called 98 | // * per every request instance 99 | // eslint-disable-next-line require-atomic-updates 100 | request.pid = pid; 101 | // eslint-disable-next-line require-atomic-updates 102 | request.paramPack = paramPackData; 103 | 104 | return next(); 105 | } 106 | 107 | function badAuth(response: express.Response, errorCode: number, message: string): void { 108 | response.type('application/xml'); 109 | response.status(400); 110 | 111 | response.send(xmlbuilder.create({ 112 | result: { 113 | has_error: 1, 114 | version: 1, 115 | code: 400, 116 | error_code: errorCode, 117 | message: message 118 | } 119 | }).end({ pretty: true })); 120 | } 121 | 122 | function serverError(response: express.Response, discovery: HydratedEndpointDocument): void { 123 | let message = ''; 124 | let error = 0; 125 | 126 | switch (discovery.status) { 127 | case 1: 128 | message = 'SYSTEM_UPDATE_REQUIRED'; 129 | error = 1; 130 | break; 131 | case 2: 132 | message = 'SETUP_NOT_COMPLETE'; 133 | error = 2; 134 | break; 135 | case 3: 136 | message = 'SERVICE_MAINTENANCE'; 137 | error = 3; 138 | break; 139 | case 4: 140 | message = 'SERVICE_CLOSED'; 141 | error = 4; 142 | break; 143 | case 5: 144 | message = 'PARENTAL_CONTROLS_ENABLED'; 145 | error = 5; 146 | break; 147 | case 6: 148 | message = 'POSTING_LIMITED_PARENTAL_CONTROLS'; 149 | error = 6; 150 | break; 151 | case 7: 152 | message = 'NNID_BANNED'; 153 | error = 7; 154 | break; 155 | default: 156 | message = 'SERVER_ERROR'; 157 | error = 15; 158 | break; 159 | } 160 | 161 | response.type('application/xml'); 162 | response.status(400); 163 | 164 | response.send(xmlbuilder.create({ 165 | result: { 166 | has_error: 1, 167 | version: 1, 168 | code: 400, 169 | error_code: error, 170 | message: message 171 | } 172 | }).end({ pretty: true })); 173 | } 174 | 175 | export default auth; 176 | -------------------------------------------------------------------------------- /src/middleware/client-header.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import { getValueFromHeaders } from '@/util'; 4 | 5 | const VALID_CLIENT_ID_SECRET_PAIRS: { [key: string]: string } = { 6 | // * 'Key' is the client ID, 'Value' is the client secret 7 | 'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Possibly WiiU exclusive? 8 | 'daf6227853bcbdce3d75baee8332b': '3eff548eac636e2bf45bb7b375e7b6b0', // * Possibly 3DS exclusive? 9 | 'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55', // * Possibly 3DS exclusive? 10 | }; 11 | 12 | 13 | function nintendoClientHeaderCheck(request: express.Request, response: express.Response, next: express.NextFunction): void { 14 | response.type('text/xml'); 15 | response.set('Server', 'Nintendo 3DS (http)'); 16 | response.set('X-Nintendo-Date', new Date().getTime().toString()); 17 | 18 | const clientID = getValueFromHeaders(request.headers, 'x-nintendo-client-id'); 19 | const clientSecret = getValueFromHeaders(request.headers, 'x-nintendo-client-secret'); 20 | 21 | if ( 22 | !clientID || 23 | !clientSecret || 24 | !VALID_CLIENT_ID_SECRET_PAIRS[clientID] || 25 | clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID] 26 | ) { 27 | response.type('application/xml'); 28 | response.send(xmlbuilder.create({ 29 | errors: { 30 | error: { 31 | cause: 'client_id', 32 | code: '0004', 33 | message: 'API application invalid or incorrect application credentials' 34 | } 35 | } 36 | }).end()); 37 | 38 | return; 39 | } 40 | 41 | return next(); 42 | } 43 | 44 | export default nintendoClientHeaderCheck; -------------------------------------------------------------------------------- /src/models/community.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { CommunityData } from '@/types/miiverse/community'; 3 | import { ICommunity, ICommunityMethods, CommunityModel, HydratedCommunityDocument } from '@/types/mongoose/community'; 4 | 5 | const CommunitySchema = new Schema({ 6 | platform_id: Number, 7 | name: String, 8 | description: String, 9 | open: { 10 | type: Boolean, 11 | default: true 12 | }, 13 | allows_comments: { 14 | type: Boolean, 15 | default: true 16 | }, 17 | /** 18 | * 0: Main Community 19 | * 1: Sub-Community 20 | * 2: Announcement Community 21 | * 3: Private Community 22 | */ 23 | type: { 24 | type: Number, 25 | default: 0 26 | }, 27 | parent: { 28 | type: String, 29 | default: null 30 | }, 31 | admins: { 32 | type: [Number], 33 | default: undefined 34 | }, 35 | owner: Number, 36 | created_at: { 37 | type: Date, 38 | default: new Date(), 39 | }, 40 | empathy_count: { 41 | type: Number, 42 | default: 0 43 | }, 44 | followers: { 45 | type: Number, 46 | default: 0 47 | }, 48 | has_shop_page: { 49 | type: Number, 50 | default: 0 51 | }, 52 | icon: String, 53 | title_ids: { 54 | type: [String], 55 | default: undefined 56 | }, 57 | title_id: { 58 | type: [String], 59 | default: undefined 60 | }, 61 | community_id: String, 62 | olive_community_id: String, 63 | is_recommended: { 64 | type: Number, 65 | default: 0 66 | }, 67 | app_data: String, 68 | user_favorites: { 69 | type: [Number], 70 | default: [] 71 | } 72 | }); 73 | 74 | CommunitySchema.method('addUserFavorite', async function addUserFavorite(pid: number): Promise { 75 | if (!this.user_favorites.includes(pid)) { 76 | this.user_favorites.push(pid); 77 | } 78 | 79 | await this.save(); 80 | }); 81 | 82 | CommunitySchema.method('delUserFavorite', async function delUserFavorite(pid: number): Promise { 83 | if (this.user_favorites.includes(pid)) { 84 | this.user_favorites.splice(this.user_favorites.indexOf(pid), 1); 85 | } 86 | 87 | await this.save(); 88 | }); 89 | 90 | CommunitySchema.method('json', function json(): CommunityData { 91 | return { 92 | community_id: this.community_id, 93 | name: this.name, 94 | description: this.description, 95 | icon: this.icon.replace(/[^A-Za-z0-9+/=\s]/g, ''), 96 | icon_3ds: '', 97 | pid: this.owner, 98 | app_data: this.app_data.replace(/[^A-Za-z0-9+/=\s]/g, ''), 99 | is_user_community: '0' 100 | }; 101 | }); 102 | 103 | export const Community = model('Community', CommunitySchema); -------------------------------------------------------------------------------- /src/models/content.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { IContent, ContentModel } from '@/types/mongoose/content'; 3 | 4 | const ContentSchema = new Schema({ 5 | pid: Number, 6 | followed_communities: { 7 | type: [String], 8 | default: [0] 9 | }, 10 | followed_users: { 11 | type: [Number], 12 | default: [0] 13 | }, 14 | following_users: { 15 | type: [Number], 16 | default: [0] 17 | } 18 | }); 19 | 20 | export const Content = model('Content', ContentSchema); 21 | -------------------------------------------------------------------------------- /src/models/conversation.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { Snowflake } from 'node-snowflake'; 3 | import { IConversation, IConversationMethods, ConversationModel, HydratedConversationDocument } from '@/types/mongoose/conversation'; 4 | 5 | const ConversationSchema = new Schema({ 6 | id: { 7 | type: String, 8 | default: Snowflake.nextId() 9 | }, 10 | created_at: { 11 | type: Date, 12 | default: new Date(), 13 | }, 14 | last_updated: { 15 | type: Date, 16 | default: new Date(), 17 | }, 18 | message_preview: { 19 | type: String, 20 | default: '' 21 | }, 22 | users: [{ 23 | pid: Number, 24 | official: { 25 | type: Boolean, 26 | default: false 27 | }, 28 | read: { 29 | type: Boolean, 30 | default: true 31 | } 32 | }] 33 | }); 34 | 35 | ConversationSchema.method('newMessage', async function newMessage(message: string, senderPID: number) { 36 | if (this.users[0].pid === senderPID) { 37 | this.users[1].read = false; 38 | this.markModified('users[1].read'); 39 | } else { 40 | this.users[0].read = false; 41 | this.markModified('users[0].read'); 42 | } 43 | 44 | this.last_updated = new Date(); 45 | this.message_preview = message; 46 | 47 | await this.save(); 48 | }); 49 | 50 | export const Conversation = model('Conversation', ConversationSchema); -------------------------------------------------------------------------------- /src/models/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { IEndpoint, EndpointModel } from '@/types/mongoose/endpoint'; 3 | 4 | const endpointSchema = new Schema({ 5 | status: Number, 6 | server_access_level: String, 7 | topics: Boolean, 8 | guest_access: Boolean, 9 | host: String, 10 | api_host: String, 11 | portal_host: String, 12 | n3ds_host: String 13 | }); 14 | 15 | export const Endpoint = model('Endpoint', endpointSchema); 16 | -------------------------------------------------------------------------------- /src/models/notification.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { INotification, NotificationModel } from '@/types/mongoose/notification'; 3 | 4 | const NotificationSchema = new Schema({ 5 | pid: String, 6 | type: String, 7 | link: String, 8 | objectID: String, 9 | users: [{ 10 | user: String, 11 | timestamp: Date 12 | }], 13 | read: Boolean, 14 | lastUpdated: Date 15 | }); 16 | 17 | export const Notification = model('Notification', NotificationSchema); 18 | -------------------------------------------------------------------------------- /src/models/post.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | import moment from 'moment'; 3 | import { Schema, model } from 'mongoose'; 4 | import { HydratedPostDocument, IPost, IPostMethods, PostModel } from '@/types/mongoose/post'; 5 | import { HydratedCommunityDocument } from '@/types/mongoose/community'; 6 | import { PostToJSONOptions } from '@/types/mongoose/post-to-json-options'; 7 | import { PostData, PostPainting, PostScreenshot, PostTopicTag } from '@/types/miiverse/post'; 8 | 9 | const PostSchema = new Schema({ 10 | id: String, 11 | title_id: String, 12 | screen_name: String, 13 | body: String, 14 | app_data: String, 15 | painting: String, 16 | screenshot: String, 17 | screenshot_length: Number, 18 | search_key: { 19 | type: [String], 20 | default: undefined 21 | }, 22 | topic_tag: { 23 | type: String, 24 | default: undefined 25 | }, 26 | community_id: { 27 | type: String, 28 | default: undefined 29 | }, 30 | created_at: Date, 31 | feeling_id: Number, 32 | is_autopost: { 33 | type: Number, 34 | default: 0 35 | }, 36 | is_community_private_autopost: { 37 | type: Number, 38 | default: 0 39 | }, 40 | is_spoiler: { 41 | type: Number, 42 | default: 0 43 | }, 44 | is_app_jumpable: { 45 | type: Number, 46 | default: 0 47 | }, 48 | empathy_count: { 49 | type: Number, 50 | default: 0, 51 | min: 0 52 | }, 53 | country_id: { 54 | type: Number, 55 | default: 49 56 | }, 57 | language_id: { 58 | type: Number, 59 | default: 1 60 | }, 61 | mii: String, 62 | mii_face_url: String, 63 | pid: Number, 64 | platform_id: Number, 65 | region_id: Number, 66 | parent: String, 67 | reply_count: { 68 | type: Number, 69 | default: 0 70 | }, 71 | verified: { 72 | type: Boolean, 73 | default: false 74 | }, 75 | message_to_pid: { 76 | type: String, 77 | default: null 78 | }, 79 | removed: { 80 | type: Boolean, 81 | default: false 82 | }, 83 | removed_reason: String, 84 | yeahs: [Number], 85 | number: Number 86 | }, { 87 | id: false // * Disables the .id() getter used by Mongoose in TypeScript. Needed to have our own .id field 88 | }); 89 | 90 | 91 | PostSchema.method('del', async function del(reason: string) { 92 | this.removed = true; 93 | this.removed_reason = reason; 94 | await this.save(); 95 | }); 96 | 97 | PostSchema.method('generatePostUID', async function generatePostUID(length: number) { 98 | const id = Buffer.from(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(length * 2))), 'binary').toString('base64').replace(/[+/]/g, '').substring(0, length); 99 | 100 | const inuse = await Post.findOne({ id }); 101 | 102 | if (inuse) { 103 | await this.generatePostUID(length); 104 | } else { 105 | this.id = id; 106 | } 107 | }); 108 | 109 | PostSchema.method('cleanedBody', function cleanedBody(): string { 110 | return this.body ? this.body.replace(/[^A-Za-z\d\s-_!@#$%^&*(){}+=,.<>/?;:'"[\]]/g, '').replace(/[\n\r]+/gm, '') : ''; 111 | }); 112 | 113 | PostSchema.method('cleanedMiiData', function cleanedMiiData(): string { 114 | return this.mii.replace(/[^A-Za-z0-9+/=]/g, '').replace(/[\n\r]+/gm, '').trim(); 115 | }); 116 | 117 | PostSchema.method('cleanedPainting', function cleanedPainting(): string { 118 | return this.painting.replace(/[\n\r]+/gm, '').trim(); 119 | }); 120 | 121 | PostSchema.method('cleanedAppData', function cleanedAppData(): string { 122 | return this.app_data.replace(/[^A-Za-z0-9+/=]/g, '').replace(/[\n\r]+/gm, '').trim(); 123 | }); 124 | 125 | PostSchema.method('formatPainting', function formatPainting(): PostPainting | undefined { 126 | if (this.painting) { 127 | return { 128 | format: 'tga', 129 | content: this.cleanedPainting(), 130 | size: this.painting.length, 131 | url: `https://pretendo-cdn.b-cdn.net/paintings/${this.pid}/${this.id}.png` 132 | }; 133 | } 134 | }); 135 | 136 | PostSchema.method('formatScreenshot', function formatScreenshot(): PostScreenshot | undefined { 137 | if (this.screenshot && this.screenshot_length) { 138 | return { 139 | size: this.screenshot_length, 140 | url: `https://pretendo-cdn.b-cdn.net/screenshots/${this.pid}/${this.id}.jpg` 141 | }; 142 | } 143 | }); 144 | 145 | PostSchema.method('formatTopicTag', function formatTopicTag(): PostTopicTag | undefined { 146 | if (this.topic_tag?.trim()) { 147 | return { 148 | name: this.topic_tag, 149 | title_id: this.title_id 150 | }; 151 | } 152 | }); 153 | 154 | PostSchema.method('json', function json(options: PostToJSONOptions, community?: HydratedCommunityDocument): PostData { 155 | const post: PostData = { 156 | app_data: undefined, // TODO - I try to keep these fields in the real order they show up in, but idk where this one goes 157 | body: this.cleanedBody(), 158 | community_id: this.community_id, // TODO - This sucks 159 | country_id: this.country_id, 160 | created_at: moment(this.created_at).format('YYYY-MM-DD HH:MM:SS'), 161 | feeling_id: this.feeling_id, 162 | id: this.id, 163 | is_autopost: this.is_autopost ? 1 : 0, 164 | is_community_private_autopost: this.is_community_private_autopost ? 1 : 0, 165 | is_spoiler: this.is_spoiler ? 1 : 0, 166 | is_app_jumpable: this.is_app_jumpable ? 1 : 0, 167 | empathy_count: this.empathy_count || 0, 168 | language_id: this.language_id, 169 | mii: undefined, // * Conditionally set later 170 | mii_face_url: undefined, // * Conditionally set later 171 | number: 0, 172 | painting: this.formatPainting(), 173 | pid: this.pid, 174 | platform_id: this.platform_id, 175 | region_id: this.region_id, 176 | reply_count: this.reply_count || 0, 177 | screen_name: this.screen_name, 178 | screenshot: this.formatScreenshot(), 179 | topic_tag: undefined, // * Conditionally set later 180 | title_id: this.title_id, 181 | }; 182 | 183 | if (options.app_data) { 184 | post.app_data = this.cleanedAppData(); 185 | } 186 | 187 | if (options.with_mii) { 188 | post.mii = this.cleanedMiiData(); 189 | post.mii_face_url = this.mii_face_url; 190 | } 191 | 192 | if (options.topic_tag) { 193 | post.topic_tag = this.formatTopicTag(); 194 | } 195 | 196 | if (community) { 197 | post.community_id = community.community_id; 198 | } 199 | 200 | // * Some sanity checks 201 | if (post.feeling_id > 5) { 202 | post.feeling_id = 0; 203 | } 204 | 205 | return post; 206 | }); 207 | 208 | PostSchema.pre('save', async function(next) { 209 | if (!this.id) { 210 | await this.generatePostUID(21); 211 | } 212 | 213 | next(); 214 | }); 215 | 216 | export const Post = model('Post', PostSchema); 217 | -------------------------------------------------------------------------------- /src/models/report.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { IReport, ReportModel } from '@/types/mongoose/report'; 3 | 4 | const ReportSchema = new Schema({ 5 | pid: String, 6 | post_id: String, 7 | reason: Number, 8 | created_at: { 9 | type: Date, 10 | default: new Date() 11 | } 12 | }); 13 | 14 | export const Report = model('Report', ReportSchema); 15 | -------------------------------------------------------------------------------- /src/models/settings.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | import { SettingsData } from '@/types/miiverse/settings'; 3 | import { HydratedSettingsDocument, ISettings, ISettingsMethods, SettingsModel } from '@/types/mongoose/settings'; 4 | 5 | const SettingsSchema = new Schema({ 6 | pid: Number, 7 | screen_name: String, 8 | account_status: { 9 | type: Number, 10 | default: 0 11 | }, 12 | ban_lift_date: Date, 13 | ban_reason: String, 14 | profile_comment: { 15 | type: String, 16 | default: undefined 17 | }, 18 | profile_comment_visibility: { 19 | type: Boolean, 20 | default: true 21 | }, 22 | game_skill: { 23 | type: Number, 24 | default: 0 25 | }, 26 | game_skill_visibility: { 27 | type: Boolean, 28 | default: true 29 | }, 30 | birthday_visibility: { 31 | type: Boolean, 32 | default: false 33 | }, 34 | relationship_visibility: { 35 | type: Boolean, 36 | default: false 37 | }, 38 | country_visibility: { 39 | type: Boolean, 40 | default: false 41 | }, 42 | profile_favorite_community_visibility: { 43 | type: Boolean, 44 | default: true 45 | }, 46 | receive_notifications: { 47 | type: Boolean, 48 | default: true 49 | } 50 | }); 51 | 52 | SettingsSchema.method('json', function json(): SettingsData { 53 | return { 54 | pid: this.pid, 55 | screen_name: this.screen_name 56 | }; 57 | }); 58 | 59 | export const Settings = model('Settings', SettingsSchema); 60 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | process.title = 'Pretendo - Miiverse'; 2 | 3 | import express from 'express'; 4 | import morgan from 'morgan'; 5 | import xmlbuilder from 'xmlbuilder'; 6 | import { connect as connectDatabase } from '@/database'; 7 | import { LOG_INFO, LOG_SUCCESS } from '@/logger'; 8 | import auth from '@/middleware/auth'; 9 | 10 | import discovery from '@/services/discovery'; 11 | import api from '@/services/api'; 12 | 13 | import { config } from '@/config-manager'; 14 | 15 | const { http: { port } } = config; 16 | const app = express(); 17 | 18 | app.set('etag', false); 19 | app.disable('x-powered-by'); 20 | 21 | // Create router 22 | LOG_INFO('Setting up Middleware'); 23 | app.use(morgan('dev')); 24 | app.use(express.json()); 25 | 26 | app.use(express.urlencoded({ 27 | extended: true, 28 | limit: '5mb', 29 | parameterLimit: 100000 30 | })); 31 | app.use(auth); 32 | 33 | // import the servers into one 34 | app.use(discovery); 35 | app.use(api); 36 | 37 | // 404 handler 38 | LOG_INFO('Creating 404 status handler'); 39 | app.use((_request: express.Request, response: express.Response) => { 40 | response.type('application/xml'); 41 | response.status(404); 42 | 43 | return response.send(xmlbuilder.create({ 44 | result: { 45 | has_error: 1, 46 | version: 1, 47 | code: 404, 48 | message: 'Not Found' 49 | } 50 | }).end({ pretty: true })); 51 | }); 52 | 53 | // non-404 error handler 54 | LOG_INFO('Creating non-404 status handler'); 55 | app.use((_error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => { 56 | const status = 500; 57 | response.type('application/xml'); 58 | response.status(404); 59 | 60 | return response.send(xmlbuilder.create({ 61 | result: { 62 | has_error: 1, 63 | version: 1, 64 | code: status, 65 | message: 'Not Found' 66 | } 67 | }).end({ pretty: true })); 68 | }); 69 | 70 | async function main(): Promise { 71 | // Starts the server 72 | LOG_INFO('Starting server'); 73 | 74 | await connectDatabase(); 75 | 76 | app.listen(port, () => { 77 | LOG_SUCCESS(`Server started on port ${port}`); 78 | }); 79 | } 80 | 81 | main().catch(console.error); -------------------------------------------------------------------------------- /src/services/api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import subdomain from 'express-subdomain'; 3 | import { LOG_INFO } from '@/logger'; 4 | 5 | import postsHandlers from '@/services/api/routes/posts'; 6 | import friendMessagesHandlers from '@/services/api/routes/friend_messages'; 7 | import communitiesHandlers from '@/services/api/routes/communities'; 8 | import peopleHandlers from '@/services/api/routes/people'; 9 | import topicsHandlers from '@/services/api/routes/topics'; 10 | import usersHandlers from '@/services/api/routes/users'; 11 | import statusHandlers from '@/services/api/routes/status'; 12 | 13 | // Main router for endpointsindex.js 14 | const router = express.Router(); 15 | 16 | // Router to handle the subdomain restriction 17 | const api = express.Router(); 18 | 19 | // Create subdomains 20 | LOG_INFO('[MIIVERSE] Creating \'api\' subdomain'); 21 | router.use(subdomain('api.olv', api)); 22 | router.use(subdomain('api-test.olv', api)); 23 | router.use(subdomain('api-dev.olv', api)); 24 | 25 | // Setup routes 26 | api.use('/v1/posts', postsHandlers); 27 | api.use('/v1/posts.search', postsHandlers); 28 | api.use('/v1/friend_messages', friendMessagesHandlers); 29 | api.use('/v1/communities/', communitiesHandlers); 30 | api.use('/v1/people/', peopleHandlers); 31 | api.use('/v1/topics/', topicsHandlers); 32 | api.use('/v1/users/', usersHandlers); 33 | api.use('/v1/status/', statusHandlers); 34 | 35 | export default router; -------------------------------------------------------------------------------- /src/services/api/routes/communities.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import multer from 'multer'; 4 | import { z } from 'zod'; 5 | import { 6 | getMostPopularCommunities, 7 | getNewCommunities, 8 | getCommunityByTitleID, 9 | getUserContent, 10 | } from '@/database'; 11 | import { getValueFromQueryString } from '@/util'; 12 | import { LOG_WARN } from '@/logger'; 13 | import { Community } from '@/models/community'; 14 | import { Post } from '@/models/post'; 15 | import { HydratedCommunityDocument } from '@/types/mongoose/community'; 16 | import { SubCommunityQuery } from '@/types/mongoose/subcommunity-query'; 17 | import { CommunityPostsQuery } from '@/types/mongoose/community-posts-query'; 18 | import { HydratedPostDocument, IPost } from '@/types/mongoose/post'; 19 | import { ParamPack } from '@/types/common/param-pack'; 20 | import { CommunitiesResult, CommunityPostsResult } from '@/types/miiverse/community'; 21 | 22 | const createNewCommunitySchema = z.object({ 23 | name: z.string(), 24 | description: z.string().optional(), 25 | icon: z.string(), 26 | app_data: z.string().optional() 27 | }); 28 | 29 | const router = express.Router(); 30 | 31 | function respondCommunityError(response: express.Response, httpStatusCode: number, errorCode: number): void { 32 | response.status(httpStatusCode).send(xmlbuilder.create({ 33 | result: { 34 | has_error: 1, 35 | version: 1, 36 | code: httpStatusCode, 37 | error_code: errorCode, 38 | message: 'COMMUNITY_ERROR' // This field is unused by the entire nn_olv.rpl 39 | } 40 | }).end({ pretty: true })); 41 | } 42 | 43 | function respondCommunityNotFound(response: express.Response): void { 44 | respondCommunityError(response, 404, 919); 45 | } 46 | 47 | async function commonGetSubCommunity(paramPack: ParamPack, communityID: string | undefined): Promise { 48 | 49 | const parentCommunity = await getCommunityByTitleID(paramPack.title_id); 50 | 51 | if (!parentCommunity) { 52 | return null; 53 | } 54 | 55 | const query = { 56 | parent: parentCommunity.olive_community_id, 57 | community_id: communityID 58 | }; 59 | 60 | const community = await Community.findOne(query); 61 | 62 | if (!community) { 63 | return null; 64 | } 65 | 66 | return community; 67 | } 68 | 69 | /* GET post titles. */ 70 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 71 | response.type('application/xml'); 72 | 73 | const parentCommunity = await getCommunityByTitleID(request.paramPack.title_id); 74 | if (!parentCommunity) { 75 | respondCommunityNotFound(response); 76 | return; 77 | } 78 | 79 | const type = getValueFromQueryString(request.query, 'type')[0]; 80 | const limitString = getValueFromQueryString(request.query, 'limit')[0]; 81 | 82 | let limit = 4; 83 | 84 | if (limitString) { 85 | limit = parseInt(limitString); 86 | } 87 | 88 | if (isNaN(limit)) { 89 | limit = 4; 90 | } 91 | 92 | if (limit > 16) { 93 | limit = 16; 94 | } 95 | 96 | const query: SubCommunityQuery = { 97 | parent: parentCommunity.olive_community_id 98 | }; 99 | 100 | if (type === 'my') { 101 | query.owner = request.pid; 102 | } else if (type === 'favorite') { 103 | query.user_favorites = request.pid; 104 | } 105 | 106 | const communities = await Community.find(query).limit(limit); 107 | 108 | const result: CommunitiesResult = { 109 | has_error: 0, 110 | version: 1, 111 | request_name: 'communities', 112 | communities: [] 113 | }; 114 | 115 | for (const community of communities) { 116 | result.communities.push({ 117 | community: community.json() 118 | }); 119 | } 120 | 121 | response.send(xmlbuilder.create({ 122 | result 123 | }, { 124 | separateArrayItems: true 125 | }).end({ 126 | pretty: true, 127 | allowEmpty: true 128 | })); 129 | }); 130 | 131 | router.get('/popular', async function (_request: express.Request, response: express.Response): Promise { 132 | const popularCommunities = await getMostPopularCommunities(100); 133 | 134 | response.type('application/json'); 135 | response.send(popularCommunities); 136 | }); 137 | 138 | router.get('/new', async function (_request: express.Request, response: express.Response): Promise { 139 | const newCommunities = await getNewCommunities(100); 140 | 141 | response.type('application/json'); 142 | response.send(newCommunities); 143 | }); 144 | 145 | router.get('/:communityID/posts', async function (request: express.Request, response: express.Response): Promise { 146 | response.type('application/xml'); 147 | 148 | let community = await Community.findOne({ 149 | community_id: request.params.communityID 150 | }); 151 | 152 | if (!community) { 153 | community = await getCommunityByTitleID(request.paramPack.title_id); 154 | } 155 | 156 | if (!community) { 157 | return respondCommunityNotFound(response); 158 | } 159 | 160 | const query: CommunityPostsQuery = { 161 | community_id: community.olive_community_id, 162 | removed: false, 163 | app_data: { $ne: null }, 164 | message_to_pid: { $eq: null } 165 | }; 166 | 167 | const searchKey = getValueFromQueryString(request.query, 'search_key')[0]; 168 | const allowSpoiler = getValueFromQueryString(request.query, 'allow_spoiler')[0]; 169 | const postType = getValueFromQueryString(request.query, 'type')[0]; 170 | const queryBy = getValueFromQueryString(request.query, 'by')[0]; 171 | const distinctPID = getValueFromQueryString(request.query, 'distinct_pid')[0]; 172 | const limitString = getValueFromQueryString(request.query, 'limit')[0]; 173 | const withMii = getValueFromQueryString(request.query, 'with_mii')[0]; 174 | 175 | let limit = 10; 176 | 177 | if (limitString) { 178 | limit = parseInt(limitString); 179 | } 180 | 181 | if (isNaN(limit)) { 182 | limit = 10; 183 | } 184 | 185 | if (searchKey) { 186 | query.search_key = searchKey; 187 | } 188 | 189 | if (!allowSpoiler) { 190 | query.is_spoiler = 0; 191 | } 192 | 193 | //TODO: There probably is a type for text and screenshots too, will have to investigate 194 | if (postType === 'memo') { 195 | query.painting = { $ne: null }; 196 | } 197 | 198 | if (queryBy === 'followings') { 199 | const userContent = await getUserContent(request.pid); 200 | 201 | if (!userContent) { 202 | LOG_WARN(`USER PID ${request.pid} HAS NO USER CONTENT`); 203 | query.pid = []; 204 | } else { 205 | query.pid = userContent.following_users; 206 | } 207 | } else if (queryBy === 'self') { 208 | query.pid = request.pid; 209 | } 210 | 211 | let posts: HydratedPostDocument[]; 212 | 213 | if (distinctPID && distinctPID === '1') { 214 | const unhydratedPosts = await Post.aggregate([ 215 | { $match: query }, // filter based on input query 216 | { $sort: { created_at: -1 } }, // sort by 'created_at' in descending order 217 | { $group: { _id: '$pid', doc: { $first: '$$ROOT' } } }, // remove any duplicate 'pid' elements 218 | { $replaceRoot: { newRoot: '$doc' } }, // replace the root with the 'doc' field 219 | { $limit: limit } // only return the top 10 results 220 | ]); 221 | posts = unhydratedPosts.map((post: IPost) => Post.hydrate(post)); 222 | } else { 223 | posts = await Post.find(query).sort({ created_at: -1 }).limit(limit); 224 | } 225 | 226 | const result: CommunityPostsResult = { 227 | has_error: 0, 228 | version: 1, 229 | request_name: 'posts', 230 | topic: { 231 | community_id: community.community_id 232 | }, 233 | posts: [] 234 | }; 235 | 236 | for (const post of posts) { 237 | result.posts.push({ 238 | post: post.json({ 239 | with_mii: withMii === '1', 240 | app_data: true, 241 | topic_tag: true 242 | }) 243 | }); 244 | } 245 | 246 | response.send(xmlbuilder.create({ 247 | result 248 | }, { 249 | separateArrayItems: true 250 | }).end({ 251 | pretty: true, 252 | allowEmpty: true 253 | })); 254 | }); 255 | 256 | // Handler for POST on '/v1/communities' 257 | router.post('/', multer().none(), async function (request: express.Request, response: express.Response): Promise { 258 | response.type('application/xml'); 259 | 260 | const parentCommunity = await getCommunityByTitleID(request.paramPack.title_id); 261 | if (!parentCommunity) { 262 | return respondCommunityNotFound(response); 263 | } 264 | 265 | // TODO - Better error codes, maybe do defaults? 266 | const bodyCheck = createNewCommunitySchema.safeParse(request.body); 267 | if (!bodyCheck.success) { 268 | return respondCommunityError(response, 400, 20); 269 | } 270 | 271 | request.body.name = request.body.name.trim(); 272 | request.body.icon = request.body.icon.trim(); 273 | 274 | if (request.body.description) { 275 | request.body.description = request.body.description.trim(); 276 | } 277 | 278 | if (request.body.app_data) { 279 | request.body.app_data = request.body.app_data.trim(); 280 | } 281 | 282 | // Name must be at least 4 character long 283 | if (request.body.name.length < 4) { 284 | return respondCommunityError(response, 400, 20); 285 | } 286 | 287 | // Each user can only have 4 subcommunities per title 288 | const ownedQuery = { 289 | parent: parentCommunity.olive_community_id, 290 | owner: request.pid 291 | }; 292 | 293 | const ownedSubcommunityCount = await Community.countDocuments(ownedQuery); 294 | if (ownedSubcommunityCount >= 4) { 295 | return respondCommunityError(response, 401, 911); 296 | } 297 | 298 | // Each user can only have 16 favorite subcommunities per title 299 | const favoriteQuery = { 300 | parent: parentCommunity.olive_community_id, 301 | user_favorites: request.pid 302 | }; 303 | 304 | const ownedFavoriteCount = await Community.countDocuments(favoriteQuery); 305 | if (ownedFavoriteCount >= 16) { 306 | return respondCommunityError(response, 401, 912); 307 | } 308 | 309 | const communitiesCount = await Community.count(); 310 | const communityID = (parseInt(parentCommunity.community_id) + (5000 * communitiesCount)); // Change this to auto increment 311 | const community = await Community.create({ 312 | platform_id: 0, // WiiU 313 | name: request.body.name, 314 | description: request.body.description || '', 315 | open: true, 316 | allows_comments: true, 317 | type: 1, 318 | parent: parentCommunity.olive_community_id, 319 | admins: parentCommunity.admins, 320 | owner: request.pid, 321 | icon: request.body.icon, 322 | title_id: request.paramPack.title_id, 323 | community_id: communityID.toString(), 324 | olive_community_id: communityID.toString(), 325 | app_data: request.body.app_data || '', 326 | user_favorites: [request.pid] 327 | }); 328 | 329 | response.send(xmlbuilder.create({ 330 | result: { 331 | has_error: '0', 332 | version: '1', 333 | request_name: 'community', 334 | community: community.json() 335 | } 336 | }).end({ 337 | pretty: true, 338 | allowEmpty: true 339 | })); 340 | }); 341 | 342 | router.post('/:community_id.delete', multer().none(), async function (request: express.Request, response: express.Response): Promise { 343 | response.type('application/xml'); 344 | 345 | const community = await commonGetSubCommunity(request.paramPack, request.params.community_id); 346 | 347 | if (!community) { 348 | respondCommunityNotFound(response); 349 | return; 350 | } 351 | 352 | if (community.owner != request.pid) { 353 | response.sendStatus(403); // Forbidden 354 | return; 355 | } 356 | 357 | await Community.deleteOne({ _id: community._id }); 358 | 359 | response.send(xmlbuilder.create({ 360 | result: { 361 | has_error: '0', 362 | version: '1', 363 | request_name: 'community', 364 | community: community.json() 365 | } 366 | }).end({ pretty: true, allowEmpty: true })); 367 | }); 368 | 369 | router.post('/:community_id.favorite', multer().none(), async function (request: express.Request, response: express.Response): Promise { 370 | response.type('application/xml'); 371 | 372 | const community = await commonGetSubCommunity(request.paramPack, request.params.community_id); 373 | 374 | if (!community) { 375 | respondCommunityNotFound(response); 376 | return; 377 | } 378 | 379 | // Each user can only have 16 favorite subcommunities per title 380 | const favoriteQuery = { 381 | parent: community.parent, 382 | user_favorites: request.pid 383 | }; 384 | 385 | const ownedFavoriteCount = await Community.countDocuments(favoriteQuery); 386 | if (ownedFavoriteCount >= 16) { 387 | return respondCommunityError(response, 401, 914); 388 | } 389 | 390 | await community.addUserFavorite(request.pid); 391 | 392 | response.send(xmlbuilder.create({ 393 | result: { 394 | has_error: '0', 395 | version: '1', 396 | request_name: 'community', 397 | community: community.json() 398 | } 399 | }).end({ 400 | pretty: true, 401 | allowEmpty: true 402 | })); 403 | }); 404 | 405 | router.post('/:community_id.unfavorite', multer().none(), async function (request: express.Request, response: express.Response): Promise { 406 | response.type('application/xml'); 407 | 408 | const community = await commonGetSubCommunity(request.paramPack, request.params.community_id); 409 | if (!community) { 410 | respondCommunityNotFound(response); 411 | return; 412 | } 413 | 414 | // You can't remove from your favorites a community you own 415 | if (community.owner === request.pid) { 416 | return respondCommunityError(response, 401, 916); 417 | } 418 | 419 | await community.delUserFavorite(request.pid); 420 | 421 | response.send(xmlbuilder.create({ 422 | result: { 423 | has_error: '0', 424 | version: '1', 425 | request_name: 'community', 426 | community: community.json() 427 | } 428 | }).end({ 429 | pretty: true, 430 | allowEmpty: true 431 | })); 432 | }); 433 | 434 | 435 | router.post('/:community_id', multer().none(), async function (request: express.Request, response: express.Response): Promise { 436 | response.type('application/xml'); 437 | 438 | const community = await commonGetSubCommunity(request.paramPack, request.params.community_id); 439 | 440 | if (!community) { 441 | respondCommunityNotFound(response); 442 | return; 443 | } 444 | 445 | if (community.owner != request.pid) { 446 | response.sendStatus(403); // Forbidden 447 | return; 448 | } 449 | 450 | if (request.body.name) { 451 | community.name = request.body.name.trim(); 452 | } 453 | 454 | if (request.body.description) { 455 | community.description = request.body.description.trim(); 456 | } 457 | 458 | if (request.body.icon) { 459 | community.icon = request.body.icon.trim(); 460 | } 461 | 462 | if (request.body.app_data) { 463 | community.app_data = request.body.app_data.trim(); 464 | } 465 | 466 | await community.save(); 467 | 468 | response.send(xmlbuilder.create({ 469 | result: { 470 | has_error: '0', 471 | version: '1', 472 | request_name: 'community', 473 | community: community.json() 474 | } 475 | }).end({ 476 | pretty: true, 477 | allowEmpty: true 478 | })); 479 | }); 480 | 481 | export default router; 482 | -------------------------------------------------------------------------------- /src/services/api/routes/friend_messages.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import multer from 'multer'; 3 | import { Snowflake } from 'node-snowflake'; 4 | import moment from 'moment'; 5 | import xmlbuilder from 'xmlbuilder'; 6 | import { z } from 'zod'; 7 | import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; 8 | import { getUserFriendPIDs, getUserAccountData, processPainting, uploadCDNAsset, getValueFromQueryString } from '@/util'; 9 | import { getConversationByUsers, getUserSettings, getFriendMessages } from '@/database'; 10 | import { LOG_WARN } from '@/logger'; 11 | import { Post } from '@/models/post'; 12 | import { Conversation } from '@/models/conversation'; 13 | import { FormattedMessage } from '@/types/common/formatted-message'; 14 | 15 | const sendMessageSchema = z.object({ 16 | message_to_pid: z.string().transform(Number), 17 | body: z.string(), 18 | painting: z.string().optional(), 19 | screenshot: z.string().optional(), 20 | app_data: z.string().optional() 21 | }); 22 | 23 | const router = express.Router(); 24 | const upload = multer(); 25 | 26 | router.post('/', upload.none(), async function (request: express.Request, response: express.Response): Promise { 27 | response.type('application/xml'); 28 | 29 | // TODO - Better error codes, maybe do defaults? 30 | const bodyCheck = sendMessageSchema.safeParse(request.body); 31 | 32 | if (!bodyCheck.success) { 33 | response.status(422); 34 | return; 35 | } 36 | 37 | const recipientPID = bodyCheck.data.message_to_pid; 38 | let messageBody = bodyCheck.data.body; 39 | const painting = bodyCheck.data.painting?.replace(/\0/g, '').trim() || ''; 40 | const screenshot = bodyCheck.data.screenshot?.trim().replace(/\0/g, '').trim() || ''; 41 | const appData = bodyCheck.data.app_data?.replace(/[^A-Za-z0-9+/=\s]/g, '').trim() || ''; 42 | 43 | if (isNaN(recipientPID)) { 44 | response.status(422); 45 | return; 46 | } 47 | 48 | let sender: GetUserDataResponse; 49 | 50 | try { 51 | sender = await getUserAccountData(request.pid); 52 | } catch (error) { 53 | // TODO - Log this error 54 | response.status(422); 55 | return; 56 | } 57 | 58 | if (!sender.mii) { 59 | // * This should never happen, but TypeScript complains so check anyway 60 | // TODO - Better errors 61 | response.status(422); 62 | return; 63 | } 64 | 65 | let recipient: GetUserDataResponse; 66 | 67 | try { 68 | recipient = await getUserAccountData(request.pid); 69 | } catch (error) { 70 | // TODO - Log this error 71 | response.status(422); 72 | return; 73 | } 74 | 75 | let conversation = await getConversationByUsers([sender.pid, recipient.pid]); 76 | 77 | if (!conversation) { 78 | const userSettings = await getUserSettings(request.pid); 79 | const user2Settings = await getUserSettings(recipient.pid); 80 | 81 | if (!sender || !recipient || userSettings || user2Settings) { 82 | response.sendStatus(422); 83 | return; 84 | } 85 | 86 | conversation = await Conversation.create({ 87 | id: Snowflake.nextId(), 88 | users: [ 89 | { 90 | pid: sender.pid, 91 | official: (sender.accessLevel === 2 || sender.accessLevel === 3), 92 | read: true 93 | }, 94 | { 95 | pid: recipient.pid, 96 | official: (recipient.accessLevel === 2 || recipient.accessLevel === 3), 97 | read: false 98 | }, 99 | ] 100 | }); 101 | } 102 | 103 | if (!conversation) { 104 | response.sendStatus(404); 105 | return; 106 | } 107 | 108 | const friendPIDs = await getUserFriendPIDs(recipient.pid); 109 | 110 | if (friendPIDs.indexOf(request.pid) === -1) { 111 | response.sendStatus(422); 112 | return; 113 | } 114 | 115 | let miiFace = 'normal_face.png'; 116 | switch (parseInt(request.body.feeling_id)) { 117 | case 1: 118 | miiFace = 'smile_open_mouth.png'; 119 | break; 120 | case 2: 121 | miiFace = 'wink_left.png'; 122 | break; 123 | case 3: 124 | miiFace = 'surprise_open_mouth.png'; 125 | break; 126 | case 4: 127 | miiFace = 'frustrated.png'; 128 | break; 129 | case 5: 130 | miiFace = 'sorrow.png'; 131 | break; 132 | } 133 | 134 | if (messageBody) { 135 | messageBody = messageBody.replace(/[^A-Za-z\d\s-_!@#$%^&*(){}‛¨ƒºª«»“”„¿¡←→↑↓√§¶†‡¦–—⇒⇔¤¢€£¥™©®+×÷=±∞ˇ˘˙¸˛˜′″µ°¹²³♭♪•…¬¯‰¼½¾♡♥●◆■▲▼☆★♀♂,./?;:'"\\<>]/g, ''); 136 | } 137 | 138 | if (messageBody.length > 280) { 139 | messageBody = messageBody.substring(0, 280); 140 | } 141 | 142 | if (messageBody === '' && painting === '' && screenshot === '') { 143 | response.status(422); 144 | response.redirect(`/friend_messages/${conversation.id}`); 145 | return; 146 | } 147 | 148 | const post = await Post.create({ 149 | title_id: request.paramPack.title_id, 150 | community_id: conversation.id, 151 | screen_name: sender.mii.name, 152 | body: messageBody, 153 | app_data: appData, 154 | painting: painting, 155 | screenshot: '', 156 | screenshot_length: 0, 157 | country_id: request.paramPack.country_id, 158 | created_at: new Date(), 159 | feeling_id: request.body.feeling_id, 160 | search_key: request.body.search_key, 161 | topic_tag: request.body.topic_tag, 162 | is_autopost: request.body.is_autopost, 163 | is_spoiler: (request.body.spoiler) ? 1 : 0, 164 | is_app_jumpable: request.body.is_app_jumpable, 165 | language_id: request.body.language_id, 166 | mii: sender.mii.data, 167 | mii_face_url: `https://mii.olv.pretendo.cc/mii/${sender.pid}/${miiFace}`, 168 | pid: request.pid, 169 | platform_id: request.paramPack.platform_id, 170 | region_id: request.paramPack.region_id, 171 | verified: (sender.accessLevel === 2 || sender.accessLevel === 3), 172 | message_to_pid: request.body.message_to_pid, 173 | parent: null, 174 | removed: false 175 | }); 176 | 177 | if (painting) { 178 | const paintingBuffer = await processPainting(painting); 179 | 180 | if (paintingBuffer) { 181 | await uploadCDNAsset('pn-cdn', `paintings/${request.pid}/${post.id}.png`, paintingBuffer, 'public-read'); 182 | } else { 183 | LOG_WARN(`PAINTING FOR POST ${post.id} FAILED TO PROCESS`); 184 | } 185 | } 186 | 187 | if (screenshot) { 188 | const screenshotBuffer = Buffer.from(screenshot, 'base64'); 189 | 190 | await uploadCDNAsset('pn-cdn', `screenshots/${request.pid}/${post.id}.jpg`, screenshotBuffer, 'public-read'); 191 | 192 | post.screenshot = `/screenshots/${request.pid}/${post.id}.jpg`; 193 | post.screenshot_length = screenshot.length; 194 | 195 | await post.save(); 196 | } 197 | 198 | let postPreviewText = messageBody; 199 | if (painting) { 200 | postPreviewText = 'sent a Drawing'; 201 | } else if (messageBody.length > 25) { 202 | postPreviewText = messageBody.substring(0, 25) + '...'; 203 | } 204 | 205 | await conversation.newMessage(postPreviewText, recipientPID); 206 | 207 | response.sendStatus(200); 208 | }); 209 | 210 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 211 | response.type('application/xml'); 212 | 213 | const limitString = getValueFromQueryString(request.query, 'limit')[0]; 214 | 215 | // TODO - Is this the limit? 216 | let limit = 10; 217 | 218 | if (limitString) { 219 | limit = parseInt(limitString); 220 | } 221 | 222 | if (isNaN(limit)) { 223 | limit = 10; 224 | } 225 | 226 | if (!request.query.search_key) { 227 | response.sendStatus(404); 228 | return; 229 | } 230 | 231 | const searchKey = getValueFromQueryString(request.query, 'search_key'); 232 | 233 | const messages = await getFriendMessages(request.pid.toString(), searchKey, limit); 234 | 235 | const postBody: FormattedMessage[] = []; 236 | for (const message of messages) { 237 | postBody.push({ 238 | post: { 239 | body: message.body, 240 | country_id: message.country_id || 0, 241 | created_at: moment(message.created_at).format('YYYY-MM-DD HH:MM:SS'), 242 | feeling_id: message.feeling_id || 0, 243 | id: message.id, 244 | is_autopost: message.is_autopost, 245 | is_spoiler: message.is_spoiler, 246 | is_app_jumpable: message.is_app_jumpable, 247 | empathy_added: message.empathy_count, 248 | language_id: message.language_id, 249 | message_to_pid: message.message_to_pid, 250 | mii: message.mii, 251 | mii_face_url: message.mii_face_url, 252 | number: message.number || 0, 253 | pid: message.pid, 254 | platform_id: message.platform_id || 0, 255 | region_id: message.region_id || 0, 256 | reply_count: message.reply_count, 257 | screen_name: message.screen_name, 258 | topic_tag: { 259 | name: message.topic_tag, 260 | title_id: 0 261 | }, 262 | title_id: message.title_id 263 | } 264 | }); 265 | } 266 | 267 | response.send(xmlbuilder.create({ 268 | result: { 269 | has_error: 0, 270 | version: 1, 271 | request_name: 'friend_messages', 272 | posts: postBody 273 | } 274 | }, { separateArrayItems: true }).end({ pretty: true })); 275 | }); 276 | 277 | router.post('/:post_id/empathies', upload.none(), async function (_request: express.Request, response: express.Response): Promise { 278 | response.type('application/xml'); 279 | // TODO - FOR JEMMA! FIX THIS! MISSING MONGOOSE SCHEMA METHODS 280 | // * Remove the underscores from request and response to make them seen by eslint again 281 | /* 282 | let pid = getPIDFromServiceToken(req.headers["x-nintendo-servicetoken"]); 283 | const post = await getPostByID(req.params.post_id); 284 | if(pid === null) { 285 | res.sendStatus(403); 286 | return; 287 | } 288 | let user = await getUserByPID(pid); 289 | if(user.likes.indexOf(post.id) === -1 && user.id !== post.pid) 290 | { 291 | post.upEmpathy(); 292 | user.addToLikes(post.id) 293 | res.sendStatus(200); 294 | } 295 | else 296 | res.sendStatus(403); 297 | */ 298 | }); 299 | 300 | export default router; -------------------------------------------------------------------------------- /src/services/api/routes/people.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import moment from 'moment'; 4 | import { getUserContent, getFollowedUsers } from '@/database'; 5 | import { getValueFromQueryString, getUserFriendPIDs } from '@/util'; 6 | import { Post } from '@/models/post'; 7 | import { CommunityPostsQuery } from '@/types/mongoose/community-posts-query'; 8 | import { HydratedPostDocument, IPost } from '@/types/mongoose/post'; 9 | import { PeopleFollowingResult, PeoplePostsResult } from '@/types/miiverse/people'; 10 | 11 | const router = express.Router(); 12 | 13 | /* GET post titles. */ 14 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 15 | response.type('application/xml'); 16 | 17 | const userContent = await getUserContent(request.pid); 18 | 19 | if (!userContent) { 20 | response.sendStatus(404); 21 | return; 22 | } 23 | 24 | const query: CommunityPostsQuery = { 25 | removed: false, 26 | is_spoiler: 0, 27 | app_data: { $eq: null }, 28 | parent: { $eq: null }, 29 | message_to_pid: { $eq: null } 30 | }; 31 | 32 | const relation = getValueFromQueryString(request.query, 'relation')[0]; 33 | const distinctPID = getValueFromQueryString(request.query, 'distinct_pid')[0]; 34 | const limitString = getValueFromQueryString(request.query, 'limit')[0]; 35 | const withMii = getValueFromQueryString(request.query, 'with_mii')[0]; 36 | 37 | let limit = 10; 38 | 39 | if (limitString) { 40 | limit = parseInt(limitString); 41 | } 42 | 43 | if (isNaN(limit)) { 44 | limit = 10; 45 | } 46 | 47 | if (relation === 'friend') { 48 | query.pid = { $in: await getUserFriendPIDs(request.pid) }; 49 | } else if (relation === 'following') { 50 | query.pid = { $in: userContent.followed_users }; 51 | } else if (request.query.pid) { 52 | const pidInputs = getValueFromQueryString(request.query, 'pid'); 53 | const pids = pidInputs.map(pid => Number(pid)).filter(pid => !isNaN(pid)); 54 | 55 | query.pid = { $in: pids }; 56 | } 57 | 58 | let posts: HydratedPostDocument[]; 59 | 60 | if (distinctPID === '1') { 61 | const unhydratedPosts = await Post.aggregate([ 62 | { $match: query }, // filter based on input query 63 | { $sort: { created_at: -1 } }, // sort by 'created_at' in descending order 64 | { $group: { _id: '$pid', doc: { $first: '$$ROOT' } } }, // remove any duplicate 'pid' elements 65 | { $replaceRoot: { newRoot: '$doc' } }, // replace the root with the 'doc' field 66 | { $limit: limit } // only return the top 10 results 67 | ]); 68 | 69 | posts = unhydratedPosts.map((post: IPost) => Post.hydrate(post)); 70 | } else if (request.query.is_hot === '1') { 71 | posts = await Post.find(query).sort({ empathy_count: -1}).limit(limit); 72 | } else { 73 | posts = await Post.find(query).sort({ created_at: -1}).limit(limit); 74 | } 75 | 76 | const result: PeoplePostsResult = { 77 | has_error: 0, 78 | version: 1, 79 | expire: moment().add(1, 'days').format('YYYY-MM-DD HH:MM:SS'), 80 | request_name: 'posts', 81 | people: [] 82 | }; 83 | 84 | for (const post of posts) { 85 | result.people.push({ 86 | person: { 87 | posts: [ 88 | { 89 | post: post.json({ 90 | with_mii: withMii === '1', 91 | topic_tag: true 92 | }) 93 | } 94 | ] 95 | } 96 | }); 97 | } 98 | 99 | response.send(xmlbuilder.create({ 100 | result 101 | }, { 102 | separateArrayItems: true 103 | }).end({ 104 | pretty: true, 105 | allowEmpty: true 106 | })); 107 | }); 108 | 109 | router.get('/:pid/following', async function (request: express.Request, response: express.Response): Promise { 110 | response.type('application/xml'); 111 | 112 | const pid = parseInt(request.params.pid); 113 | 114 | if (isNaN(pid)) { 115 | response.sendStatus(404); 116 | return; 117 | } 118 | 119 | const userContent = await getUserContent(pid); 120 | 121 | if (!userContent) { 122 | response.sendStatus(404); 123 | return; 124 | } 125 | 126 | const people = await getFollowedUsers(userContent); 127 | 128 | const result: PeopleFollowingResult = { 129 | has_error: 0, 130 | version: 1, 131 | request_name: 'user_infos', 132 | people: [] 133 | }; 134 | 135 | for (const person of people) { 136 | result.people.push({ 137 | person: person.json() 138 | }); 139 | } 140 | 141 | response.send(xmlbuilder.create({ 142 | result 143 | }, { 144 | separateArrayItems: true 145 | }).end({ 146 | pretty: true, 147 | allowEmpty: true 148 | })); 149 | }); 150 | 151 | export default router; -------------------------------------------------------------------------------- /src/services/api/routes/posts.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import multer from 'multer'; 3 | import xmlbuilder from 'xmlbuilder'; 4 | import { z } from 'zod'; 5 | import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; 6 | import { getUserAccountData, processPainting, uploadCDNAsset, getValueFromQueryString } from '@/util'; 7 | import { 8 | getPostByID, 9 | getUserContent, 10 | getPostReplies, 11 | getUserSettings, 12 | getCommunityByID, 13 | getCommunityByTitleID, 14 | getDuplicatePosts 15 | } from '@/database'; 16 | import { LOG_WARN } from '@/logger'; 17 | import { Post } from '@/models/post'; 18 | import { Community } from '@/models/community'; 19 | import { HydratedPostDocument } from '@/types/mongoose/post'; 20 | import { PostRepliesResult } from '@/types/miiverse/post'; 21 | 22 | const newPostSchema = z.object({ 23 | community_id: z.string().optional(), 24 | app_data: z.string().optional(), 25 | painting: z.string().optional(), 26 | screenshot: z.string().optional(), 27 | body: z.string().optional(), 28 | feeling_id: z.string(), 29 | search_key: z.string().array().or(z.string()).optional(), 30 | topic_tag: z.string().optional(), 31 | is_autopost: z.string(), 32 | is_spoiler: z.string().optional(), 33 | is_app_jumpable: z.string().optional(), 34 | language_id: z.string() 35 | }); 36 | 37 | const router = express.Router(); 38 | const upload = multer(); 39 | 40 | /* GET post titles. */ 41 | router.post('/', upload.none(), newPost); 42 | 43 | router.post('/:post_id/replies', upload.none(), newPost); 44 | 45 | router.post('/:post_id.delete', async function (request: express.Request, response: express.Response): Promise { 46 | response.type('application/xml'); 47 | 48 | const post = await getPostByID(request.params.post_id); 49 | const userContent = await getUserContent(request.pid); 50 | 51 | if (!post || !userContent) { 52 | response.sendStatus(504); 53 | return; 54 | } 55 | 56 | if (post.pid === userContent.pid) { 57 | await post.del('User requested removal'); 58 | response.sendStatus(200); 59 | } else { 60 | response.sendStatus(401); 61 | } 62 | }); 63 | 64 | router.post('/:post_id/empathies', upload.none(), async function (request: express.Request, response: express.Response): Promise { 65 | response.type('application/xml'); 66 | 67 | const post = await getPostByID(request.params.post_id); 68 | 69 | if (!post) { 70 | response.sendStatus(404); 71 | return; 72 | } 73 | 74 | if (post.yeahs?.indexOf(request.pid) === -1) { 75 | await Post.updateOne({ 76 | id: post.id, 77 | yeahs: { 78 | $ne: request.pid 79 | } 80 | }, 81 | { 82 | $inc: { 83 | empathy_count: 1 84 | }, 85 | $push: { 86 | yeahs: request.pid 87 | } 88 | }); 89 | } else if (post.yeahs?.indexOf(request.pid) !== -1) { 90 | await Post.updateOne({ 91 | id: post.id, 92 | yeahs: { 93 | $eq: request.pid 94 | } 95 | }, 96 | { 97 | $inc: { 98 | empathy_count: -1 99 | }, 100 | $pull: { 101 | yeahs: request.pid 102 | } 103 | }); 104 | } 105 | 106 | response.sendStatus(200); 107 | }); 108 | 109 | router.get('/:post_id/replies', async function (request: express.Request, response: express.Response): Promise { 110 | response.type('application/xml'); 111 | 112 | const limitString = getValueFromQueryString(request.query, 'limit')[0]; 113 | 114 | let limit = 10; // TODO - Is there a real limit? 115 | 116 | if (limitString) { 117 | limit = parseInt(limitString); 118 | } 119 | 120 | if (isNaN(limit)) { 121 | limit = 10; 122 | } 123 | 124 | const post = await getPostByID(request.params.post_id); 125 | 126 | if (!post) { 127 | response.sendStatus(404); 128 | return; 129 | } 130 | 131 | const posts = await getPostReplies(post.id, limit); 132 | if (posts.length === 0) { 133 | response.sendStatus(404); 134 | return; 135 | } 136 | 137 | const result: PostRepliesResult = { 138 | has_error: 0, 139 | version: 1, 140 | request_name: 'replies', 141 | posts: [] 142 | }; 143 | 144 | for (const post of posts) { 145 | result.posts.push({ 146 | post: post.json({ 147 | with_mii: request.query.with_mii as string === '1', 148 | topic_tag: true 149 | }) 150 | }); 151 | } 152 | 153 | response.send(xmlbuilder.create({ 154 | result 155 | }, { 156 | separateArrayItems: true 157 | }).end({ 158 | pretty: true, 159 | allowEmpty: true 160 | })); 161 | }); 162 | 163 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 164 | response.type('application/xml'); 165 | 166 | const postID = getValueFromQueryString(request.query, 'post_id')[0]; 167 | 168 | if (!postID) { 169 | response.type('application/xml'); 170 | response.status(404); 171 | response.send(xmlbuilder.create({ 172 | result: { 173 | has_error: 1, 174 | version: 1, 175 | code: 404, 176 | message: 'Not Found' 177 | } 178 | }).end({ pretty: true })); 179 | return; 180 | } 181 | 182 | const post = await getPostByID(postID); 183 | 184 | if (!post) { 185 | response.status(404); 186 | response.send(xmlbuilder.create({ 187 | result: { 188 | has_error: 1, 189 | version: 1, 190 | code: 404, 191 | message: 'Not Found' 192 | } 193 | }).end({ pretty: true })); 194 | return; 195 | } 196 | 197 | response.send(xmlbuilder.create({ 198 | result: { 199 | has_error: '0', 200 | version: '1', 201 | request_name: 'posts.search', 202 | posts: { 203 | post: post.json({ with_mii: true }) 204 | } 205 | } 206 | }).end({ pretty: true, allowEmpty: true })); 207 | }); 208 | 209 | async function newPost(request: express.Request, response: express.Response): Promise { 210 | response.type('application/xml'); 211 | 212 | let user: GetUserDataResponse; 213 | 214 | try { 215 | user = await getUserAccountData(request.pid); 216 | } catch (error) { 217 | // TODO - Log this error 218 | response.sendStatus(403); 219 | return; 220 | } 221 | 222 | if (!user.mii) { 223 | // * This should never happen, but TypeScript complains so check anyway 224 | // TODO - Better errors 225 | response.status(422); 226 | return; 227 | } 228 | 229 | const userSettings = await getUserSettings(request.pid); 230 | const bodyCheck = newPostSchema.safeParse(request.body); 231 | 232 | if (!userSettings || !bodyCheck.success) { 233 | response.sendStatus(403); 234 | return; 235 | } 236 | 237 | const communityID = bodyCheck.data.community_id || ''; 238 | let messageBody = bodyCheck.data.body; 239 | const painting = bodyCheck.data.painting?.replace(/\0/g, '').trim() || ''; 240 | const screenshot = bodyCheck.data.screenshot?.replace(/\0/g, '').trim() || ''; 241 | const appData = bodyCheck.data.app_data?.replace(/[^A-Za-z0-9+/=\s]/g, '').trim() || ''; 242 | const feelingID = parseInt(bodyCheck.data.feeling_id); 243 | let searchKey = bodyCheck.data.search_key || []; 244 | const topicTag = bodyCheck.data.topic_tag || ''; 245 | const autopost = bodyCheck.data.is_autopost; 246 | const spoiler = bodyCheck.data.is_spoiler; 247 | const jumpable = bodyCheck.data.is_app_jumpable; 248 | const languageID = parseInt(bodyCheck.data.language_id); 249 | const countryID = parseInt(request.paramPack.country_id); 250 | const platformID = parseInt(request.paramPack.platform_id); 251 | const regionID = parseInt(request.paramPack.region_id); 252 | 253 | if ( 254 | isNaN(feelingID) || 255 | isNaN(languageID) || 256 | isNaN(countryID) || 257 | isNaN(platformID) || 258 | isNaN(regionID) 259 | ) { 260 | response.sendStatus(403); 261 | return; 262 | } 263 | 264 | let community = await getCommunityByID(communityID); 265 | if (!community) { 266 | community = await Community.findOne({ 267 | olive_community_id: communityID 268 | }); 269 | } 270 | 271 | if (!community) { 272 | community = await getCommunityByTitleID(request.paramPack.title_id); 273 | } 274 | 275 | if (!community || userSettings.account_status !== 0 || community.community_id === 'announcements') { 276 | response.sendStatus(403); 277 | return; 278 | } 279 | 280 | let parentPost: HydratedPostDocument | null = null; 281 | if (request.params.post_id) { 282 | parentPost = await getPostByID(request.params.post_id.toString()); 283 | 284 | if (!parentPost) { 285 | response.sendStatus(403); 286 | return; 287 | } 288 | } 289 | 290 | // TODO - Clean this up 291 | // * Nesting this because of how manu checks there are, extremely unreadable otherwise 292 | if (!(community.admins && community.admins.indexOf(request.pid) !== -1 && userSettings.account_status === 0)) { 293 | if (community.type >= 2) { 294 | if (!(parentPost && community.allows_comments && community.open)) { 295 | response.sendStatus(403); 296 | return; 297 | } 298 | } 299 | } 300 | 301 | let miiFace = 'normal_face.png'; 302 | switch (parseInt(request.body.feeling_id)) { 303 | case 1: 304 | miiFace = 'smile_open_mouth.png'; 305 | break; 306 | case 2: 307 | miiFace = 'wink_left.png'; 308 | break; 309 | case 3: 310 | miiFace = 'surprise_open_mouth.png'; 311 | break; 312 | case 4: 313 | miiFace = 'frustrated.png'; 314 | break; 315 | case 5: 316 | miiFace = 'sorrow.png'; 317 | break; 318 | } 319 | 320 | if (messageBody) { 321 | messageBody = messageBody.replace(/[^A-Za-z\d\s-_!@#$%^&*(){}‛¨ƒºª«»“”„¿¡←→↑↓√§¶†‡¦–—⇒⇔¤¢€£¥™©®+×÷=±∞ˇ˘˙¸˛˜′″µ°¹²³♭♪•…¬¯‰¼½¾♡♥●◆■▲▼☆★♀♂,./?;:'"\\<>]/g, ''); 322 | } 323 | 324 | if (messageBody && messageBody.length > 280) { 325 | messageBody = messageBody.substring(0, 280); 326 | } 327 | 328 | if ((!messageBody || messageBody === '') && painting === '' && screenshot === '') { 329 | response.status(400); 330 | return; 331 | } 332 | 333 | if (!Array.isArray(searchKey)) { 334 | searchKey = [searchKey]; 335 | } 336 | 337 | const document = { 338 | id: '', // * This gets changed when saving the document for the first time 339 | title_id: request.paramPack.title_id, 340 | community_id: community.olive_community_id, 341 | screen_name: userSettings.screen_name, 342 | body: messageBody ? messageBody : '', 343 | app_data: appData, 344 | painting: painting, 345 | screenshot: '', 346 | screenshot_length: 0, 347 | country_id: countryID, 348 | created_at: new Date(), 349 | feeling_id: feelingID, 350 | search_key: searchKey, 351 | topic_tag: topicTag, 352 | is_autopost: (autopost) ? 1 : 0, 353 | is_spoiler: (spoiler === '1') ? 1 : 0, 354 | is_app_jumpable: (jumpable) ? 1 : 0, 355 | language_id: languageID, 356 | mii: user.mii.data, 357 | mii_face_url: `https://mii.olv.pretendo.cc/mii/${user.pid}/${miiFace}`, 358 | pid: request.pid, 359 | platform_id: platformID, 360 | region_id: regionID, 361 | verified: (user.accessLevel === 2 || user.accessLevel === 3), 362 | parent: parentPost ? parentPost.id : null, 363 | removed: false 364 | }; 365 | 366 | const duplicatePost = await getDuplicatePosts(request.pid, document); 367 | 368 | if (duplicatePost) { 369 | response.status(400); 370 | response.send(xmlbuilder.create({ 371 | result: { 372 | has_error: 1, 373 | version: 1, 374 | code: 400, 375 | error_code: 7, 376 | message: 'DUPLICATE_POST' 377 | } 378 | }).end({ pretty: true })); 379 | return; 380 | } 381 | 382 | const post = await Post.create(document); 383 | 384 | if (painting) { 385 | const paintingBuffer = await processPainting(painting); 386 | 387 | if (paintingBuffer) { 388 | await uploadCDNAsset('pn-cdn', `paintings/${request.pid}/${post.id}.png`, paintingBuffer, 'public-read'); 389 | } else { 390 | LOG_WARN(`PAINTING FOR POST ${post.id} FAILED TO PROCESS`); 391 | } 392 | } 393 | 394 | if (screenshot) { 395 | const screenshotBuffer = Buffer.from(screenshot, 'base64'); 396 | 397 | await uploadCDNAsset('pn-cdn', `screenshots/${request.pid}/${post.id}.jpg`, screenshotBuffer, 'public-read'); 398 | 399 | post.screenshot = `/screenshots/${request.pid}/${post.id}.jpg`; 400 | post.screenshot_length = screenshot.length; 401 | 402 | await post.save(); 403 | } 404 | 405 | if (parentPost) { 406 | parentPost.reply_count = (parentPost.reply_count || 0) + 1; 407 | parentPost.save(); 408 | } 409 | 410 | response.send(xmlbuilder.create({ 411 | result: { 412 | has_error: '0', 413 | version: '1', 414 | post: { 415 | post: post.json({ with_mii: true }) 416 | } 417 | } 418 | }).end({ pretty: true, allowEmpty: true })); 419 | } 420 | 421 | export default router; -------------------------------------------------------------------------------- /src/services/api/routes/status.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { getEndpoints } from '@/database'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', function(_request: express.Request, response: express.Response): void { 7 | response.send('Pong!'); 8 | }); 9 | 10 | router.get('/database', async function(_request: express.Request, response: express.Response): Promise { 11 | const endpoints = await getEndpoints(); 12 | 13 | if (endpoints && endpoints.length <= 0) { 14 | response.send('DB Connection Working! :D'); 15 | } else { 16 | response.send('DB Connection Not Working! D:'); 17 | } 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /src/services/api/routes/topics.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import moment from 'moment'; 3 | import xmlbuilder from 'xmlbuilder'; 4 | import Cache from '@/cache'; 5 | import { Post } from '@/models/post'; 6 | import { Community } from '@/models/community'; 7 | import { IPost } from '@/types/mongoose/post'; 8 | import { HydratedCommunityDocument } from '@/types/mongoose/community'; 9 | import { WWPResult, WWPTopic } from '@/types/miiverse/wara-wara-plaza'; 10 | 11 | const router = express.Router(); 12 | const ONE_HOUR = 60 * 60 * 1000; 13 | const WARA_WARA_PLAZA_CACHE = new Cache(ONE_HOUR); 14 | 15 | /* GET post titles. */ 16 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 17 | response.type('application/xml'); 18 | 19 | // * Commented out for now because we just don't 20 | // * need this data here. WWP does not use the 21 | // * current users data atm. Also some users have 22 | // * BOSS tasks with outdated tokens, which aren't 23 | // * usable and thus break this request. This is 24 | // * done as a quick/hacky fix around that 25 | // TODO - Re-enable this and filter out the current users posts 26 | //let user: GetUserDataResponse; 27 | // 28 | //try { 29 | // user = await getUserAccountData(request.pid); 30 | //} catch (error) { 31 | // // TODO - Log this error 32 | // response.sendStatus(403); 33 | // return; 34 | //} 35 | // 36 | //let discovery: HydratedEndpointDocument | null; 37 | // 38 | //if (user) { 39 | // discovery = await getEndpoint(user.serverAccessLevel); 40 | //} else { 41 | // discovery = await getEndpoint('prod'); 42 | //} 43 | // 44 | //if (!discovery || !discovery.topics) { 45 | // response.sendStatus(404); 46 | // return; 47 | //} 48 | 49 | if (!WARA_WARA_PLAZA_CACHE.valid()) { 50 | const communities = await calculateMostPopularCommunities(24, 10); 51 | 52 | if (communities.length < 10) { 53 | response.sendStatus(404); 54 | return; 55 | } 56 | 57 | WARA_WARA_PLAZA_CACHE.update(await generateTopicsData(communities)); 58 | } 59 | 60 | const result = WARA_WARA_PLAZA_CACHE.get() || {}; 61 | const xml = xmlbuilder.create({ 62 | result: result 63 | }, { 64 | separateArrayItems: true 65 | }).end({ 66 | pretty: true, 67 | allowEmpty: true 68 | }); 69 | 70 | response.send(xml); 71 | }); 72 | 73 | async function generateTopicsData(communities: HydratedCommunityDocument[]): Promise { 74 | const topics: { 75 | topic: WWPTopic; 76 | }[] = []; 77 | 78 | const seenPeople: number[] = []; 79 | 80 | for (let i = 0; i < communities.length; i++) { 81 | const community = communities[i]; 82 | 83 | const empathies = await Post.aggregate<{ _id: null; total: number; }>([ 84 | { 85 | $match: { 86 | community_id: community.olive_community_id 87 | } 88 | }, 89 | { 90 | $group: { 91 | _id: null, 92 | total: { 93 | $sum: '$empathy_count' 94 | } 95 | } 96 | }, 97 | { 98 | $limit: 1 99 | } 100 | ]); 101 | 102 | const topic: WWPTopic = { 103 | empathy_count: empathies[0]?.total || 0, 104 | has_shop_page: community.has_shop_page ? 1 : 0, 105 | icon: community.icon, 106 | title_ids: [], 107 | title_id: community.title_id[0], 108 | community_id: 0xFFFFFFFF, // * This is how it was in the real WWP. Unsure why, but it works 109 | is_recommended: community.is_recommended ? 1 : 0, 110 | name: community.name, 111 | people: [], 112 | position: i+1 113 | }; 114 | 115 | community.title_id.forEach(title_id => { 116 | // * Just in case 117 | if (title_id) { 118 | topic.title_ids.push({ title_id }); 119 | } 120 | }); 121 | 122 | const people = await getCommunityPeople(community, seenPeople); 123 | 124 | for (const person of people) { 125 | const post = Post.hydrate(person.post).json({ 126 | with_mii: true, 127 | topic_tag: true 128 | }); 129 | 130 | post.community_id = 0xFFFFFFFF; // * Make this match above. This is how it was in the real WWP. Unsure why, but it works 131 | 132 | topic.people.push({ 133 | person: { 134 | posts: [ 135 | { 136 | post 137 | } 138 | ] 139 | } 140 | }); 141 | 142 | seenPeople.push(person._id); 143 | } 144 | 145 | topics.push({ 146 | topic: topic 147 | }); 148 | } 149 | 150 | return { 151 | has_error: 0, 152 | version: 1, 153 | expire: moment().add(2, 'days').format('YYYY-MM-DD HH:MM:SS'), 154 | request_name: 'topics', 155 | topics 156 | }; 157 | } 158 | 159 | async function getCommunityPeople(community: HydratedCommunityDocument, seenPeople: number[], hours = 24): Promise<{ _id: number; post: IPost }[]> { 160 | const now = new Date(); 161 | const last24Hours = new Date(now.getTime() - hours * 60 * 60 * 1000); 162 | const people = await Post.aggregate<{ _id: number; post: IPost }>([ 163 | { 164 | $match: { 165 | title_id: { 166 | $in: community.title_id 167 | }, 168 | created_at: { 169 | $gte: last24Hours 170 | }, 171 | message_to_pid: null, 172 | parent: null, 173 | removed: false, 174 | pid: { 175 | // * Exclude people we have seen in other communities. 176 | // * This increases generation time, but ensures the 177 | // * max number of slots we can fill end up getting used 178 | $nin: seenPeople 179 | } 180 | } 181 | }, 182 | { 183 | $group: { 184 | _id: '$pid', 185 | post: { 186 | $first: '$$ROOT' 187 | } 188 | } 189 | }, 190 | { 191 | $limit: 70 // * Arbitrary 192 | } 193 | ]); 194 | 195 | // TODO - Remove this check once out of beta and have more users 196 | // * We only do this because Juxtaposition is not super active 197 | // * due to it being in beta. If we don't expand the search 198 | // * time range then WWP still ends up fairly empty 199 | // * 200 | // * Ensure we have at *least* 20 people. Arbitrary. 201 | // * If the year is less than 2020, assume we've gone 202 | // * too far back. There are no more posts, just return 203 | // * what was found 204 | if (people.length < 20 && last24Hours.getFullYear() >= 2020) { 205 | // * Double the search range each time to get 206 | // * exponentially more posts. This speeds up 207 | // * the search at the cost of using older posts 208 | return getCommunityPeople(community, seenPeople, hours * 2); 209 | } 210 | 211 | return people; 212 | } 213 | 214 | async function calculateMostPopularCommunities(hours: number, limit: number): Promise { 215 | const now = new Date(); 216 | const last24Hours = new Date(now.getTime() - hours * 60 * 60 * 1000); 217 | 218 | if (!last24Hours) { 219 | throw new Error('Invalid date'); 220 | } 221 | 222 | const validCommunities = await Community.aggregate<{ _id: null; communities: string[]; }>([ 223 | { 224 | $match: { 225 | type: 0, 226 | parent: null 227 | } 228 | }, 229 | { 230 | $group: { 231 | _id: null, 232 | communities: { 233 | $push: '$olive_community_id' 234 | } 235 | } 236 | } 237 | ]); 238 | 239 | const communityIDs = validCommunities[0].communities; 240 | 241 | if (!communityIDs) { 242 | throw new Error('No communities found'); 243 | } 244 | 245 | const popularCommunities = await Post.aggregate<{ _id: null; count: number; }>([ 246 | { 247 | $match: { 248 | created_at: { 249 | $gte: last24Hours 250 | }, 251 | message_to_pid: null, 252 | community_id: { 253 | $in: communityIDs 254 | } 255 | } 256 | }, 257 | { 258 | $group: { 259 | _id: '$community_id', 260 | count: { 261 | $sum: 1 262 | } 263 | } 264 | }, 265 | { 266 | $limit: limit 267 | }, 268 | { 269 | $sort: { 270 | count: -1 271 | } 272 | } 273 | ]); 274 | 275 | if (popularCommunities.length < limit) { 276 | return calculateMostPopularCommunities(hours + hours, limit); 277 | } 278 | 279 | return Community.find({ 280 | olive_community_id: { 281 | $in: popularCommunities.map(({ _id }) => _id) 282 | } 283 | }); 284 | } 285 | 286 | export default router; 287 | -------------------------------------------------------------------------------- /src/services/api/routes/users.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import { getValueFromQueryString } from '@/util'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/:pid/notifications', function(request: express.Request, response: express.Response): void { 8 | const type = getValueFromQueryString(request.query, 'type')[0]; 9 | const titleID = getValueFromQueryString(request.query, 'title_id')[0]; 10 | const pid = getValueFromQueryString(request.query, 'pid')[0]; 11 | 12 | console.log(type); 13 | console.log(titleID); 14 | console.log(pid); 15 | 16 | response.type('application/xml'); 17 | response.send(xmlbuilder.create({ 18 | result: { 19 | has_error: 0, 20 | version: 1, 21 | posts: ' ' 22 | } 23 | }).end({ pretty: true })); 24 | }); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /src/services/discovery/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import subdomain from 'express-subdomain'; 3 | import { LOG_INFO } from '@/logger'; 4 | 5 | import discoveryHandlers from '@/services/discovery/routes/discovery'; 6 | 7 | // Main router for endpointsindex.js 8 | const router = express.Router(); 9 | 10 | // Router to handle the subdomain restriction 11 | const discovery = express.Router(); 12 | 13 | // Create subdomains 14 | LOG_INFO('[MIIVERSE] Creating \'discovery\' subdomain'); 15 | router.use(subdomain('discovery.olv', discovery)); 16 | router.use(subdomain('discovery-test.olv', discovery)); 17 | router.use(subdomain('discovery-dev.olv', discovery)); 18 | 19 | // Setup routes 20 | discovery.use('/v1/endpoint', discoveryHandlers); 21 | 22 | export default router; -------------------------------------------------------------------------------- /src/services/discovery/routes/discovery.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import xmlbuilder from 'xmlbuilder'; 3 | import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; 4 | import { getUserAccountData } from '@/util'; 5 | import { getEndpoint } from '@/database'; 6 | import { HydratedEndpointDocument } from '@/types/mongoose/endpoint'; 7 | 8 | const router = express.Router(); 9 | 10 | /* GET discovery server. */ 11 | router.get('/', async function (request: express.Request, response: express.Response): Promise { 12 | response.type('application/xml'); 13 | 14 | let user: GetUserDataResponse; 15 | 16 | try { 17 | user = await getUserAccountData(request.pid); 18 | } catch (error) { 19 | // TODO - Log this error 20 | response.sendStatus(404); 21 | return; 22 | } 23 | 24 | let discovery: HydratedEndpointDocument | null; 25 | 26 | if (user) { 27 | discovery = await getEndpoint(user.serverAccessLevel); 28 | } else { 29 | discovery = await getEndpoint('prod'); 30 | } 31 | 32 | // TODO - Better error 33 | if (!discovery) { 34 | response.sendStatus(404); 35 | return; 36 | } 37 | 38 | let message = ''; 39 | let errorCode = 0; 40 | switch (discovery.status) { 41 | case 0: 42 | response.send(xmlbuilder.create({ 43 | result: { 44 | has_error: 0, 45 | version: 1, 46 | endpoint: { 47 | host: discovery.host, 48 | api_host: discovery.api_host, 49 | portal_host: discovery.portal_host, 50 | n3ds_host: discovery.n3ds_host 51 | } 52 | } 53 | }).end({ pretty: true })); 54 | 55 | return ; 56 | case 1: 57 | message = 'SYSTEM_UPDATE_REQUIRED'; 58 | errorCode = 1; 59 | break; 60 | case 2: 61 | message = 'SETUP_NOT_COMPLETE'; 62 | errorCode = 2; 63 | break; 64 | case 3: 65 | message = 'SERVICE_MAINTENANCE'; 66 | errorCode = 3; 67 | break; 68 | case 4: 69 | message = 'SERVICE_CLOSED'; 70 | errorCode = 4; 71 | break; 72 | case 5: 73 | message = 'PARENTAL_CONTROLS_ENABLED'; 74 | errorCode = 5; 75 | break; 76 | case 6: 77 | message = 'POSTING_LIMITED_PARENTAL_CONTROLS'; 78 | errorCode = 6; 79 | break; 80 | case 7: 81 | message = 'NNID_BANNED'; 82 | errorCode = 7; 83 | response.type('application/xml'); 84 | break; 85 | default: 86 | message = 'SERVER_ERROR'; 87 | errorCode = 15; 88 | response.type('application/xml'); 89 | break; 90 | } 91 | 92 | response.status(400); 93 | response.send(xmlbuilder.create({ 94 | result: { 95 | has_error: 1, 96 | version: 1, 97 | code: 400, 98 | error_code: errorCode, 99 | message: message 100 | } 101 | }).end({ pretty: true })); 102 | }); 103 | 104 | export default router; 105 | -------------------------------------------------------------------------------- /src/types/common/config.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export interface Config { 4 | http: { 5 | port: number; 6 | }; 7 | account_server_address: string; 8 | mongoose: { 9 | connection_string: string; 10 | options: mongoose.ConnectOptions; 11 | }; 12 | s3: { 13 | endpoint: string; 14 | key: string; 15 | secret: string; 16 | }; 17 | grpc: { 18 | friends: { 19 | ip: string; 20 | port: number; 21 | api_key: string; 22 | }; 23 | account: { 24 | ip: string; 25 | port: number; 26 | api_key: string; 27 | }; 28 | }; 29 | aes_key: string; 30 | } -------------------------------------------------------------------------------- /src/types/common/formatted-message.ts: -------------------------------------------------------------------------------- 1 | export interface FormattedMessage { 2 | post: { 3 | body: string; 4 | country_id: number; 5 | created_at: string; 6 | feeling_id: number; 7 | id: string; 8 | is_autopost: number; 9 | is_spoiler: number; 10 | is_app_jumpable: number; 11 | empathy_added?: number; // * Only optional because they are optional in Posts 12 | language_id: number; 13 | message_to_pid?: string; // * Only optional because they are optional in Posts 14 | mii: string; 15 | mii_face_url: string; 16 | number: number; 17 | pid: number; 18 | platform_id: number; 19 | region_id: number; 20 | reply_count?: number; // * Only optional because they are optional in Posts 21 | screen_name: string; 22 | topic_tag: { 23 | name: string; 24 | title_id: number 25 | }; 26 | title_id: string; 27 | }; 28 | } -------------------------------------------------------------------------------- /src/types/common/param-pack.ts: -------------------------------------------------------------------------------- 1 | export interface ParamPack { 2 | title_id: string; 3 | access_key: string; 4 | platform_id: string; 5 | region_id: string; 6 | language_id: string; 7 | country_id: string; 8 | area_id: string; 9 | network_restriction: string; 10 | friend_restriction: string; 11 | rating_restriction: string; 12 | rating_organization: string; 13 | transferable_id: string; 14 | tz_name: string; 15 | utc_offset: string; 16 | } -------------------------------------------------------------------------------- /src/types/common/token.ts: -------------------------------------------------------------------------------- 1 | export interface Token { 2 | system_type: number; 3 | token_type: number; 4 | pid: number; 5 | access_level: number; 6 | title_id: bigint; 7 | expire_time: bigint; 8 | } -------------------------------------------------------------------------------- /src/types/express-subdomain.d.ts: -------------------------------------------------------------------------------- 1 | // * Credit to https://github.com/bmullan91/express-subdomain/pull/61 for the types! 2 | 3 | declare module 'express-subdomain'{ 4 | import type { Request, Response, Router } from 'express'; 5 | 6 | /** 7 | * @description The subdomain function. 8 | * @param subdomain The subdomain to listen on. 9 | * @param fn The listener function, takes a response and request. 10 | * @returns A function call to the value passed as FN, or void (the next function). 11 | */ 12 | export default function subdomain( 13 | subdomain: string, 14 | fn: Router 15 | ): (req: Request, res: Response, next: () => void) => void | typeof fn; 16 | } -------------------------------------------------------------------------------- /src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import { ParamPack } from '@/types/common/param-pack'; 2 | 3 | declare global { 4 | namespace Express { 5 | interface Request { 6 | pid: number; 7 | paramPack: ParamPack 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/types/miiverse/community.ts: -------------------------------------------------------------------------------- 1 | import { PostData } from '@/types/miiverse/post'; 2 | 3 | export type CommunityData = { 4 | community_id: string; 5 | name: string; 6 | description: string; 7 | icon: string; 8 | icon_3ds: string; 9 | pid: number; 10 | app_data: string; 11 | is_user_community: string; 12 | }; 13 | 14 | export type CommunitiesResult = { 15 | has_error: 0 | 1; 16 | version: 1; 17 | request_name: 'communities'; 18 | communities: { 19 | community: CommunityData; 20 | }[]; 21 | }; 22 | 23 | export type CommunityPostsResult = { 24 | has_error: 0 | 1; 25 | version: 1; 26 | request_name: 'posts'; 27 | topic: { 28 | community_id: string; 29 | }; 30 | posts: { 31 | post: PostData; 32 | }[]; 33 | }; -------------------------------------------------------------------------------- /src/types/miiverse/people.ts: -------------------------------------------------------------------------------- 1 | import { PostData } from '@/types/miiverse/post'; 2 | import { SettingsData } from '@/types/miiverse/settings'; 3 | 4 | export type PersonPosts = { 5 | person: { 6 | posts: { 7 | post: PostData; 8 | }[]; 9 | } 10 | }; 11 | 12 | export type PeoplePostsResult = { 13 | has_error: 0 | 1; 14 | version: 1; 15 | expire: string; 16 | request_name: 'posts'; 17 | people: PersonPosts[]; 18 | }; 19 | 20 | export type PeopleFollowingResult = { 21 | has_error: 0 | 1; 22 | version: 1; 23 | request_name: 'user_infos'; 24 | people: { 25 | person: SettingsData; 26 | }[]; 27 | }; -------------------------------------------------------------------------------- /src/types/miiverse/post.ts: -------------------------------------------------------------------------------- 1 | export type PostData = { 2 | app_data?: string; // TODO - I try to keep these fields in the real order they show up in, but idk where this one goes 3 | body?: string; 4 | community_id: number | string; // TODO - Remove this union. Only done to bypass some errors which don't break anything 5 | country_id: number; 6 | created_at: string; 7 | feeling_id: number; 8 | id: string; 9 | is_autopost: 0 | 1; 10 | is_community_private_autopost: 0 | 1; 11 | is_spoiler: 0 | 1; 12 | is_app_jumpable: 0 | 1; 13 | empathy_count: number; 14 | language_id: number; 15 | mii?: string; 16 | mii_face_url?: string; 17 | number: number; 18 | painting?: PostPainting; 19 | pid: number; 20 | platform_id: number; 21 | region_id: number; 22 | reply_count: number; 23 | screen_name: string; 24 | screenshot?: PostScreenshot; 25 | topic_tag?: PostTopicTag; 26 | title_id: string; 27 | }; 28 | 29 | export type PostPainting = { 30 | format: string; 31 | content: string; 32 | size: number; 33 | url: string; 34 | }; 35 | 36 | export type PostScreenshot = { 37 | size: number; 38 | url: string; 39 | }; 40 | 41 | export type PostTopicTag = { 42 | name: string; 43 | title_id: string; 44 | }; 45 | 46 | export type PostRepliesResult = { 47 | has_error: 0 | 1; 48 | version: 1; 49 | request_name: 'replies'; 50 | posts: { 51 | post: PostData; 52 | }[]; 53 | }; -------------------------------------------------------------------------------- /src/types/miiverse/settings.ts: -------------------------------------------------------------------------------- 1 | export type SettingsData = { 2 | pid: number; 3 | screen_name: string; 4 | }; -------------------------------------------------------------------------------- /src/types/miiverse/wara-wara-plaza.ts: -------------------------------------------------------------------------------- 1 | import { PersonPosts } from '@/types/miiverse/people'; 2 | 3 | export type WWPTopic = { 4 | empathy_count: number; 5 | has_shop_page: 0 | 1; 6 | icon: string; 7 | title_ids: { 8 | title_id: string; 9 | }[]; 10 | title_id: string; 11 | community_id: number; 12 | is_recommended: 0 | 1; 13 | name: string; 14 | people: PersonPosts[]; 15 | position: number; 16 | }; 17 | 18 | export type WWPResult = { 19 | has_error: 0 | 1; 20 | version: 1; 21 | expire: string; 22 | request_name: 'topics'; 23 | topics: { 24 | topic: WWPTopic; 25 | }[]; 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /src/types/mongoose/community-posts-query.ts: -------------------------------------------------------------------------------- 1 | // TODO - Make this more generic 2 | 3 | export interface CommunityPostsQuery { 4 | community_id?: string; 5 | removed: boolean; 6 | app_data?: { 7 | $ne?: null; 8 | $eq?: null; 9 | }; 10 | message_to_pid?: { 11 | $eq: null; 12 | }; 13 | search_key?: string; 14 | is_spoiler?: 0 | 1; 15 | painting?: { 16 | $ne: null; 17 | }; 18 | pid?: number | number[] | { 19 | $in: number[]; 20 | }; 21 | parent?: { 22 | $eq: null 23 | }; 24 | } -------------------------------------------------------------------------------- /src/types/mongoose/community.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types, HydratedDocument } from 'mongoose'; 2 | import { CommunityData } from '@/types/miiverse/community'; 3 | 4 | enum COMMUNITY_TYPE { 5 | Main = 0, 6 | Sub = 1, 7 | Announcement = 2, 8 | Private = 3 9 | } 10 | 11 | export interface ICommunity { 12 | platform_id: number; 13 | name: string; 14 | description: string; 15 | open: boolean; 16 | allows_comments: boolean; 17 | type: COMMUNITY_TYPE; 18 | parent: string; 19 | admins: Types.Array; 20 | owner: number; 21 | created_at: Date; 22 | empathy_count: number; 23 | followers: number; 24 | has_shop_page: number; 25 | icon: string; 26 | title_ids: Types.Array; 27 | title_id: Types.Array; 28 | community_id: string; 29 | olive_community_id: string; 30 | is_recommended: number; 31 | app_data: string; 32 | user_favorites: Types.Array; 33 | } 34 | 35 | export interface ICommunityMethods { 36 | addUserFavorite(pid: number): Promise; 37 | delUserFavorite(pid: number): Promise; 38 | json(): CommunityData; 39 | } 40 | 41 | export type CommunityModel = Model; 42 | 43 | export type HydratedCommunityDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/content.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types, HydratedDocument } from 'mongoose'; 2 | 3 | export interface IContent { 4 | pid: number; 5 | followed_communities: Types.Array; 6 | followed_users: Types.Array; 7 | following_users: Types.Array; 8 | } 9 | 10 | export type ContentModel = Model; 11 | 12 | export type HydratedContentDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/conversation.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types, HydratedDocument } from 'mongoose'; 2 | 3 | export type ConversationUser = { 4 | pid: number; 5 | official: boolean; 6 | read: boolean; 7 | }; 8 | 9 | export interface IConversation { 10 | id: string; 11 | created_at: Date; 12 | last_updated: Date; 13 | message_preview: string, 14 | users: Types.Array; 15 | } 16 | 17 | export interface IConversationMethods { 18 | newMessage(message: string, senderPID: number): Promise; 19 | } 20 | 21 | export type ConversationModel = Model; 22 | 23 | export type HydratedConversationDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Model, HydratedDocument } from 'mongoose'; 2 | 3 | export interface IEndpoint { 4 | status: number; 5 | server_access_level: string; 6 | topics: boolean; 7 | guest_access: boolean; 8 | host: string; 9 | api_host: string; 10 | portal_host: string; 11 | n3ds_host: string; 12 | } 13 | 14 | export type EndpointModel = Model; 15 | 16 | export type HydratedEndpointDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/notification.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types, HydratedDocument } from 'mongoose'; 2 | 3 | export type NotificationUser = { 4 | user: string; 5 | timestamp: number; 6 | } 7 | 8 | export interface INotification { 9 | pid: string; 10 | type: string; 11 | link: string; 12 | objectID: string; 13 | users: Types.Array; 14 | read: boolean; 15 | lastUpdated: number; 16 | } 17 | 18 | export type NotificationModel = Model; 19 | 20 | export type HydratedNotificationDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/post-to-json-options.ts: -------------------------------------------------------------------------------- 1 | export interface PostToJSONOptions { 2 | with_mii: boolean; 3 | app_data?: boolean; 4 | topic_tag?: boolean; 5 | } -------------------------------------------------------------------------------- /src/types/mongoose/post.ts: -------------------------------------------------------------------------------- 1 | import { Model, Types, HydratedDocument } from 'mongoose'; 2 | import { HydratedCommunityDocument } from '@/types/mongoose/community'; 3 | import { PostToJSONOptions } from '@/types/mongoose/post-to-json-options'; 4 | import { PostData, PostPainting, PostScreenshot, PostTopicTag } from '@/types/miiverse/post'; 5 | 6 | export interface IPost { 7 | id: string; 8 | title_id: string; 9 | screen_name: string; 10 | body: string; 11 | app_data: string; 12 | painting: string; 13 | screenshot: string; 14 | screenshot_length: number; 15 | search_key: string[]; 16 | topic_tag: string; 17 | community_id: string; 18 | created_at: Date; 19 | feeling_id: number; 20 | is_autopost: number; 21 | is_community_private_autopost?: number; 22 | is_spoiler: number; 23 | is_app_jumpable: number; 24 | empathy_count?: number; 25 | country_id: number; 26 | language_id: number; 27 | mii: string; 28 | mii_face_url: string; 29 | pid: number; 30 | platform_id: number; 31 | region_id: number; 32 | parent: string; 33 | reply_count?: number; 34 | verified: boolean; 35 | message_to_pid?: string; 36 | removed: boolean; 37 | removed_reason?: string; 38 | yeahs?: Types.Array; 39 | number?: number; 40 | } 41 | 42 | export interface IPostMethods { 43 | del(reason: string): Promise; 44 | generatePostUID(length: number): Promise; 45 | cleanedBody(): string; 46 | cleanedMiiData(): string; 47 | cleanedPainting(): string; 48 | cleanedAppData(): string; 49 | formatPainting(): PostPainting | undefined; 50 | formatScreenshot(): PostScreenshot | undefined; 51 | formatTopicTag(): PostTopicTag | undefined; 52 | json(options: PostToJSONOptions, community?: HydratedCommunityDocument): PostData; 53 | } 54 | 55 | export type PostModel = Model; 56 | 57 | export type HydratedPostDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/report.ts: -------------------------------------------------------------------------------- 1 | import { Model, HydratedDocument } from 'mongoose'; 2 | 3 | export interface IReport { 4 | pid: string; 5 | post_id: string; 6 | reason: number; 7 | created_at: Date; 8 | } 9 | 10 | export type ReportModel = Model; 11 | 12 | export type HydratedReportDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/settings.ts: -------------------------------------------------------------------------------- 1 | import { Model, HydratedDocument } from 'mongoose'; 2 | import { SettingsData } from '@/types/miiverse/settings'; 3 | 4 | export interface ISettings { 5 | pid: number; 6 | screen_name: string; 7 | account_status: number; 8 | ban_lift_date: Date; 9 | ban_reason: string; 10 | profile_comment: string; 11 | profile_comment_visibility: boolean; 12 | game_skill: number; 13 | game_skill_visibility: boolean; 14 | birthday_visibility: boolean; 15 | relationship_visibility: boolean; 16 | country_visibility: boolean; 17 | profile_favorite_community_visibility: boolean; 18 | receive_notifications: boolean; 19 | } 20 | 21 | export interface ISettingsMethods { 22 | json(): SettingsData; 23 | } 24 | 25 | export type SettingsModel = Model; 26 | 27 | export type HydratedSettingsDocument = HydratedDocument; -------------------------------------------------------------------------------- /src/types/mongoose/subcommunity-query.ts: -------------------------------------------------------------------------------- 1 | // TODO - Make this more generic 2 | 3 | export interface SubCommunityQuery { 4 | parent: string; 5 | owner?: number; 6 | user_favorites?: number; 7 | olive_community_id?: string; 8 | community_id?: string; 9 | } -------------------------------------------------------------------------------- /src/types/node-snowflake.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-snowflake' { 2 | export interface SnowflakeInitConfig { 3 | worker_id: number; 4 | data_center_id: number; 5 | sequence: number; 6 | } 7 | 8 | export function Server(port: number): void; 9 | 10 | export const Snowflake: { 11 | init: (config: SnowflakeInitConfig) => void; 12 | nextId: (workerId?: number, dataCenterId?: number, sequence?: number) => string; 13 | }; 14 | } -------------------------------------------------------------------------------- /src/types/tga.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tga' { 2 | export interface TGAHeader { 3 | idLength: number; 4 | colorMapType: number; 5 | dataType: number; 6 | colorMapOrigin: number; 7 | colorMapLength: number; 8 | colorMapDepth: number; 9 | xOrigin: number; 10 | yOrigin: number; 11 | width: number; 12 | height: number; 13 | bitsPerPixel: number; 14 | flags: number; 15 | id: string; 16 | } 17 | 18 | export default class TGA { 19 | constructor(buf: Buffer, opt?: { dontFixAlpha: boolean }); 20 | 21 | static createTgaBuffer(width: number, height: number, pixels: Uint8Array, dontFlipY: boolean): Buffer; 22 | 23 | parseHeader(): void; 24 | parseFooter(): void; 25 | parseExtension(extensionAreaOffset: number): void; 26 | readColor(offset: number, bytesPerPixel: number): Uint8Array; 27 | readColorWithColorMap(offset: number): Uint8Array; 28 | readColorAuto(offset: number, bytesPerPixel: number, isUsingColorMap: boolean): Uint8Array; 29 | parseColorMap(): void; 30 | setPixel(pixels: Uint8Array, idx: number, color: Uint8Array): void; 31 | parsePixels(): void; 32 | parse(): void; 33 | fixForAlpha(): void; 34 | 35 | public dontFixAlpha: boolean; 36 | public _buff: Buffer; 37 | public data: Uint8Array; 38 | public currentOffset: number; 39 | 40 | public header: TGAHeader; 41 | 42 | public width: number; 43 | public height: number; 44 | 45 | public isUsingColorMap: boolean; 46 | public isUsingRLE: boolean; 47 | public isGray: boolean; 48 | 49 | public hasAlpha: boolean; 50 | 51 | public isFlipX: boolean; 52 | public isFlipY: boolean; 53 | 54 | public colorMap: Uint8Array; 55 | 56 | public pixels: Uint8Array; 57 | } 58 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | import { IncomingHttpHeaders } from 'node:http'; 3 | import TGA from 'tga'; 4 | import pako from 'pako'; 5 | import { PNG } from 'pngjs'; 6 | import aws from 'aws-sdk'; 7 | import { createChannel, createClient, Metadata } from 'nice-grpc'; 8 | import { ParsedQs } from 'qs'; 9 | import crc32 from 'crc/crc32'; 10 | import { ParamPack } from '@/types/common/param-pack'; 11 | import { config } from '@/config-manager'; 12 | import { Token } from '@/types/common/token'; 13 | 14 | import { FriendsDefinition } from '@pretendonetwork/grpc/friends/friends_service'; 15 | import { FriendRequest } from '@pretendonetwork/grpc/friends/friend_request'; 16 | 17 | import { AccountDefinition } from '@pretendonetwork/grpc/account/account_service'; 18 | import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; 19 | 20 | // * nice-grpc doesn't export ChannelImplementation so this can't be typed 21 | const gRPCFriendsChannel = createChannel(`${config.grpc.friends.ip}:${config.grpc.friends.port}`); 22 | const gRPCFriendsClient = createClient(FriendsDefinition, gRPCFriendsChannel); 23 | 24 | const gRPCAccountChannel = createChannel(`${config.grpc.account.ip}:${config.grpc.account.port}`); 25 | const gRPCAccountClient = createClient(AccountDefinition, gRPCAccountChannel); 26 | 27 | const s3 = new aws.S3({ 28 | endpoint: new aws.Endpoint(config.s3.endpoint), 29 | accessKeyId: config.s3.key, 30 | secretAccessKey: config.s3.secret 31 | }); 32 | 33 | export function decodeParamPack(paramPack: string): ParamPack { 34 | const values = Buffer.from(paramPack, 'base64').toString().split('\\'); 35 | const entries = values.filter(value => value).reduce((entries: string[][], value: string, index: number) => { 36 | if (0 === index % 2) { 37 | entries.push([value]); 38 | } else { 39 | entries[Math.ceil(index / 2 - 1)].push(value); 40 | } 41 | 42 | return entries; 43 | }, []); 44 | 45 | return Object.fromEntries(entries); 46 | } 47 | 48 | export function getPIDFromServiceToken(token: string): number { 49 | try { 50 | const decryptedToken = decryptToken(Buffer.from(token, 'base64')); 51 | 52 | if (!decryptedToken) { 53 | return 0; 54 | } 55 | 56 | const unpackedToken = unpackToken(decryptedToken); 57 | 58 | return unpackedToken.pid; 59 | } catch (e) { 60 | console.error(e); 61 | return 0; 62 | } 63 | } 64 | 65 | export function decryptToken(token: Buffer): Buffer { 66 | const iv = Buffer.alloc(16); 67 | 68 | const expectedChecksum = token.readUint32BE(); 69 | const encryptedBody = token.subarray(4); 70 | 71 | const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(config.aes_key, 'hex'), iv); 72 | 73 | const decrypted = Buffer.concat([ 74 | decipher.update(encryptedBody), 75 | decipher.final() 76 | ]); 77 | 78 | if (expectedChecksum !== crc32(decrypted)) { 79 | throw new Error('Checksum did not match. Failed decrypt. Are you using the right key?'); 80 | } 81 | 82 | return decrypted; 83 | } 84 | 85 | export function unpackToken(token: Buffer): Token { 86 | return { 87 | system_type: token.readUInt8(0x0), 88 | token_type: token.readUInt8(0x1), 89 | pid: token.readUInt32LE(0x2), 90 | expire_time: token.readBigUInt64LE(0x6), 91 | title_id: token.readBigUInt64LE(0xE), 92 | access_level: token.readInt8(0x16) 93 | }; 94 | } 95 | 96 | export function processPainting(painting: string): Buffer | null { 97 | const paintingBuffer = Buffer.from(painting, 'base64'); 98 | let output: Uint8Array; 99 | 100 | try { 101 | output = pako.inflate(paintingBuffer); 102 | } catch (error) { 103 | console.error(error); 104 | return null; 105 | } 106 | 107 | const tga = new TGA(Buffer.from(output)); 108 | const png = new PNG({ 109 | width: tga.width, 110 | height: tga.height 111 | }); 112 | 113 | png.data = Buffer.from(tga.pixels); 114 | 115 | return PNG.sync.write(png); 116 | } 117 | 118 | export async function uploadCDNAsset(bucket: string, key: string, data: Buffer, acl: string): Promise { 119 | const awsPutParams = { 120 | Body: data, 121 | Key: key, 122 | Bucket: bucket, 123 | ACL: acl 124 | }; 125 | 126 | await s3.putObject(awsPutParams).promise(); 127 | } 128 | 129 | export async function getUserFriendPIDs(pid: number): Promise { 130 | const response = await gRPCFriendsClient.getUserFriendPIDs({ 131 | pid: pid 132 | }, { 133 | metadata: Metadata({ 134 | 'X-API-Key': config.grpc.friends.api_key 135 | }) 136 | }); 137 | 138 | return response.pids; 139 | } 140 | 141 | export async function getUserFriendRequestsIncoming(pid: number): Promise { 142 | const response = await gRPCFriendsClient.getUserFriendRequestsIncoming({ 143 | pid: pid 144 | }, { 145 | metadata: Metadata({ 146 | 'X-API-Key': config.grpc.friends.api_key 147 | }) 148 | }); 149 | 150 | return response.friendRequests; 151 | } 152 | 153 | export function getUserAccountData(pid: number): Promise { 154 | return gRPCAccountClient.getUserData({ 155 | pid: pid 156 | }, { 157 | metadata: Metadata({ 158 | 'X-API-Key': config.grpc.account.api_key 159 | }) 160 | }); 161 | } 162 | 163 | export function getValueFromQueryString(qs: ParsedQs, key: string): string[] { 164 | const property = qs[key] as string | string[]; 165 | 166 | if (property) { 167 | if (Array.isArray(property)) { 168 | return property; 169 | } else { 170 | return [property]; 171 | } 172 | } 173 | 174 | return []; 175 | } 176 | 177 | export function getValueFromHeaders(headers: IncomingHttpHeaders, key: string): string | undefined { 178 | let header = headers[key]; 179 | let value: string | undefined; 180 | 181 | if (header) { 182 | if (Array.isArray(header)) { 183 | header = header[0]; 184 | } 185 | 186 | value = header; 187 | } 188 | 189 | return value; 190 | } -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import crypto from 'node:crypto'; 4 | import newman from 'newman'; 5 | import { Collection, CollectionDefinition } from 'postman-collection'; 6 | import qs from 'qs'; 7 | import axios from 'axios'; 8 | import { create as parseXML } from 'xmlbuilder2'; 9 | import { table } from 'table'; 10 | import ora from 'ora'; 11 | import dotenv from 'dotenv'; 12 | import colors from 'colors'; 13 | 14 | import communitiesCollection from '../postman/collections/Communities.json'; 15 | import peopleCollection from '../postman/collections/People.json'; 16 | 17 | const PeopleCollection: CollectionDefinition = peopleCollection as CollectionDefinition; 18 | const CommunitiesCollection: CollectionDefinition = communitiesCollection as CollectionDefinition; 19 | 20 | dotenv.config(); 21 | colors.enable(); 22 | 23 | interface TestResult { 24 | collection: string; 25 | name: string; 26 | url: string; 27 | query: string; 28 | assertion: string; 29 | error?: string 30 | } 31 | 32 | const USERNAME = process.env.PN_MIIVERSE_API_TESTING_USERNAME?.trim() || ''; 33 | const PASSWORD = process.env.PN_MIIVERSE_API_TESTING_PASSWORD?.trim() || ''; 34 | const DEVICE_ID = process.env.PN_MIIVERSE_API_TESTING_DEVICE_ID?.trim() || ''; 35 | const SERIAL_NUMBER = process.env.PN_MIIVERSE_API_TESTING_SERIAL_NUMBER?.trim() || ''; 36 | const CERTIFICATE = process.env.PN_MIIVERSE_API_TESTING_CONSOLE_CERT?.trim() || ''; 37 | 38 | if (!USERNAME) { 39 | throw new Error('PNID username missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_USERNAME'); 40 | } 41 | 42 | if (!PASSWORD) { 43 | throw new Error('PNID password missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_PASSWORD'); 44 | } 45 | 46 | if (!DEVICE_ID) { 47 | throw new Error('Console device ID missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_DEVICE_ID'); 48 | } 49 | 50 | if (!SERIAL_NUMBER) { 51 | throw new Error('Console serial number missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_SERIAL_NUMBER'); 52 | } 53 | 54 | if (!CERTIFICATE) { 55 | throw new Error('Console certificate missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_CONSOLE_CERT'); 56 | } 57 | 58 | const BASE_URL = 'https://account.pretendo.cc'; 59 | const API_URL = `${BASE_URL}/v1/api`; 60 | const MAPPED_IDS_URL = `${API_URL}/admin/mapped_ids`; 61 | const ACCESS_TOKEN_URL = `${API_URL}/oauth20/access_token/generate`; 62 | const SERVICE_TOKEN_URL = `${API_URL}/provider/service_token/@me?client_id=87cd32617f1985439ea608c2746e4610`; 63 | 64 | const DEFAULT_HEADERS = { 65 | 'X-Nintendo-Client-ID': 'a2efa818a34fa16b8afbc8a74eba3eda', 66 | 'X-Nintendo-Client-Secret': 'c91cdb5658bd4954ade78533a339cf9a', 67 | 'X-Nintendo-Device-ID': DEVICE_ID, 68 | 'X-Nintendo-Serial-Number': SERIAL_NUMBER, 69 | 'X-Nintendo-Device-Cert': CERTIFICATE 70 | }; 71 | 72 | export function nintendoPasswordHash(password: string, pid: number): string { 73 | const pidBuffer = Buffer.alloc(4); 74 | pidBuffer.writeUInt32LE(pid); 75 | 76 | const unpacked = Buffer.concat([ 77 | pidBuffer, 78 | Buffer.from('\x02\x65\x43\x46'), 79 | Buffer.from(password) 80 | ]); 81 | 82 | return crypto.createHash('sha256').update(unpacked).digest().toString('hex'); 83 | } 84 | 85 | async function apiGetRequest(url: string, headers = {}): Promise> { 86 | const response = await axios.get(url, { 87 | headers: Object.assign(headers, DEFAULT_HEADERS), 88 | validateStatus: () => true 89 | }); 90 | 91 | const data: Record = parseXML(response.data).end({ format: 'object' }); 92 | 93 | if (data.errors) { 94 | throw new Error(data.errors.error.message); 95 | } 96 | 97 | if (data.error) { 98 | throw new Error(data.error.message); 99 | } 100 | 101 | return data; 102 | } 103 | 104 | async function apiPostRequest(url: string, body: string): Promise> { 105 | const response = await axios.post(url, body, { 106 | headers: DEFAULT_HEADERS, 107 | validateStatus: () => true, 108 | }); 109 | 110 | const data: Record = parseXML(response.data).end({ format: 'object' }); 111 | 112 | if (data.errors) { 113 | throw new Error(data.errors.error.message); 114 | } 115 | 116 | if (data.error) { 117 | throw new Error(data.error.message); 118 | } 119 | 120 | return data; 121 | } 122 | 123 | async function getPID(username: string): Promise { 124 | const response = await apiGetRequest(`${MAPPED_IDS_URL}?input_type=user_id&output_type=pid&input=${username}`); 125 | 126 | return Number(response.mapped_ids.mapped_id.out_id); 127 | } 128 | 129 | async function getAccessToken(username: string, passwordHash: string): Promise { 130 | const data = qs.stringify({ 131 | grant_type: 'password', 132 | user_id: username, 133 | password: passwordHash, 134 | password_type: 'hash', 135 | }); 136 | 137 | const response = await apiPostRequest(ACCESS_TOKEN_URL, data); 138 | 139 | return response.OAuth20.access_token.token; 140 | } 141 | 142 | async function getMiiverseServiceToken(accessToken: string): Promise { 143 | const response = await apiGetRequest(SERVICE_TOKEN_URL, { 144 | 'X-Nintendo-Title-ID': '0005001010040100', 145 | Authorization: `Bearer ${accessToken}` 146 | }); 147 | 148 | return response.service_token.token; 149 | } 150 | 151 | function runNewmanTest(collection: string | Collection | CollectionDefinition, variables: Record): Promise { 152 | return new Promise((resolve, reject) => { 153 | newman.run({ 154 | collection: collection, 155 | reporters: ['json'], 156 | envVar: Object.entries(variables).map(entry => ({ key: entry[0], value: entry[1] })), 157 | globals: variables, 158 | globalVar: Object.entries(variables).map(entry => ({ key: entry[0], value: entry[1] })), 159 | }, (error, summary) => { 160 | if (error) { 161 | reject(error); 162 | } else { 163 | resolve(createTestResults(summary)); 164 | } 165 | }); 166 | }); 167 | } 168 | 169 | function communitiesRoutesTest(serviceToken: string): Promise { 170 | // TODO - Make this more dynamic? 171 | return runNewmanTest(CommunitiesCollection, { 172 | DOMAIN: 'api.olv.pretendo.cc', 173 | ServiceToken: serviceToken, 174 | // TODO - Change these names. Should not be game-specific 175 | PP_Splatoon: 'XHRpdGxlX2lkXDE0MDczNzUxNTM1MjI5NDRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc', 176 | PP_MarioVsDK: 'XHRpdGxlX2lkXDE0MDczNzUxNTMzMzcwODhcYWNjZXNzX2tleVw2OTI0NzQ1MTBccGxhdGZvcm1faWRcMVxyZWdpb25faWRcMlxsYW5ndWFnZV9pZFwxXGNvdW50cnlfaWRcNDlcYXJlYV9pZFwwXG5ldHdvcmtfcmVzdHJpY3Rpb25cMFxmcmllbmRfcmVzdHJpY3Rpb25cMFxyYXRpbmdfcmVzdHJpY3Rpb25cMTdccmF0aW5nX29yZ2FuaXphdGlvblwxXHRyYW5zZmVyYWJsZV9pZFw3NjA4MjAyOTE2MDc1ODg0NDI1XHR6X25hbWVcUGFjaWZpYy9NaWR3YXlcdXRjX29mZnNldFwtMzk2MDBc', 177 | PP_Bad_TID: 'XHRpdGxlX2lkXDEyMzRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc', 178 | PP_ACPlaza: 'XHRpdGxlX2lkXDE0MDczNzUxNTMzMjE0NzJcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc', 179 | 'PP_Bad Format': 'XHR' 180 | }); 181 | } 182 | 183 | function peopleRoutesTest(serviceToken: string): Promise { 184 | // TODO - Make this more dynamic? 185 | return runNewmanTest(PeopleCollection, { 186 | DOMAIN: 'api.olv.pretendo.cc', 187 | ServiceToken: serviceToken, 188 | // TODO - Change this name. Should not be game-specific 189 | PP_Splatoon: 'XHRpdGxlX2lkXDE0MDczNzUxNTM1MjI5NDRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc', 190 | }); 191 | } 192 | 193 | async function main(): Promise { 194 | const tokensSpinner = ora('Acquiring account tokens').start(); 195 | 196 | const pid = await getPID(USERNAME); 197 | const passwordHash = nintendoPasswordHash(PASSWORD, pid); 198 | const accessToken = await getAccessToken(USERNAME, passwordHash); 199 | const serviceToken = await getMiiverseServiceToken(accessToken); 200 | 201 | tokensSpinner.succeed(); 202 | 203 | const testsSpinner = ora('Running tests').start(); 204 | 205 | const results: TestResult[] = [ 206 | ...await communitiesRoutesTest(serviceToken), 207 | ...await peopleRoutesTest(serviceToken) 208 | ]; 209 | 210 | const passed = results.filter(result => !result.error); 211 | const failed = results.filter(result => result.error); 212 | 213 | if (failed.length !== 0) { 214 | testsSpinner.warn('Some tests have failed! See below for details'); 215 | } else { 216 | testsSpinner.succeed('All tests passed!'); 217 | } 218 | 219 | const testsOverviewData = [ 220 | ['Tests Ran'.cyan, results.length.toString().cyan], 221 | ['Passed'.green, passed.length.toString().green] 222 | ]; 223 | 224 | if (failed.length === 0) { 225 | testsOverviewData.push(['Failed'.red, failed.length.toString().green]); 226 | } else { 227 | testsOverviewData.push(['Failed'.red, failed.length.toString().red]); 228 | } 229 | 230 | const config = { 231 | singleLine: true, 232 | border: { 233 | topBody: '─', 234 | topJoin: '┬', 235 | topLeft: '┌', 236 | topRight: '┐', 237 | 238 | bottomBody: '─', 239 | bottomJoin: '┴', 240 | bottomLeft: '└', 241 | bottomRight: '┘', 242 | 243 | bodyLeft: '│', 244 | bodyRight: '│', 245 | bodyJoin: '│', 246 | 247 | joinBody: '─', 248 | joinLeft: '├', 249 | joinRight: '┤', 250 | joinJoin: '┼' 251 | } 252 | }; 253 | 254 | console.log(table(testsOverviewData, config)); 255 | 256 | if (failed.length !== 0) { 257 | console.log('Failed tests:\n'.red.underline.italic.bold); 258 | for (const test of failed) { 259 | console.log('Collection:'.bold, test.collection.red.bold); 260 | console.log('Test Name:'.bold, test.name.red.bold); 261 | console.log('URL:'.bold, `${test.url}${test.query ? '?' + test.query : ''}`.red.bold); 262 | console.log('Message:'.bold, test.error?.red.bold); 263 | console.log('\n'); 264 | } 265 | } 266 | } 267 | 268 | main(); 269 | 270 | function createTestResults(summary: newman.NewmanRunSummary): TestResult[] { 271 | const results: TestResult[] = []; 272 | 273 | for (const execution of summary.run.executions) { 274 | const request = execution.request; 275 | for (const assertion of execution.assertions) { 276 | const result: TestResult = { 277 | collection: summary.collection.name, 278 | name: execution.item.name, 279 | url: `${request.url.protocol}://${request.url.host?.join('.')}/${request.url.path?.join('/')}`, 280 | query: qs.stringify(request.url.query.all().reduce((object: Record, item: { disabled?: boolean; key: string | null; value: string | null; }) => { 281 | if (!item.disabled && item.key && item.value) { 282 | object[item.key] = item.value; 283 | } 284 | return object; 285 | }, {})), 286 | assertion: assertion.assertion 287 | }; 288 | 289 | if (assertion.error) { 290 | result.error = `${assertion.error.name}: ${assertion.error.message}`; 291 | } 292 | 293 | results.push(result); 294 | } 295 | } 296 | 297 | return results; 298 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "sourceMap": true, 5 | "resolveJsonModule": true, 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "baseUrl": "src", 10 | "outDir": "dist", 11 | "allowJs": true, 12 | "target": "es2022", 13 | "noEmitOnError": true, 14 | "noImplicitAny": true, 15 | "strictPropertyInitialization": true, 16 | "paths": { 17 | "@/*": ["./*"] 18 | } 19 | }, 20 | "include": ["src"] 21 | } --------------------------------------------------------------------------------