├── .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 | }
--------------------------------------------------------------------------------