├── .dockerignore ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── fly.toml ├── go.mod ├── go.sum ├── main.go └── pkg ├── action_comment.go ├── auth.go ├── cleanup_users.go ├── convert_response.go ├── error.go ├── fill_test_data.go ├── mask.go ├── models ├── badge.go ├── comment.go ├── comment_action.go ├── model.go ├── post.go ├── post_action.go ├── ring.go └── user.go ├── pagination.go ├── platform └── auth │ └── auth.go ├── posts_create.go ├── rc_comments.go ├── reddit_compat ├── comments.go ├── kind_data.go ├── listing.go ├── post.go ├── reddit_test.go ├── subreddit.go └── subreddit_details.go ├── reddit_convert.go ├── reddit_short_types.go ├── repo_comments.go ├── repo_comments_test.go ├── repo_posts.go ├── repo_posts_votes.go ├── repo_ring.go ├── repo_rings.go ├── repo_rings_search.go ├── repo_rings_test.go ├── repo_users.go ├── repository.go ├── request ├── comment.go ├── create_ring.go └── post_create.go ├── response ├── paginated.go └── post.go ├── ring_about.go ├── route_index.go ├── route_post.go ├── route_rings.go ├── routes.go ├── server.go ├── server_post.go ├── test_utils.go └── validation.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | resources/reddit 3 | 4 | # flyctl launch added from .idea/.gitignore 5 | # Default ignored files 6 | .idea/shelf 7 | .idea/workspace.xml 8 | # Editor-based HTTP Client requests 9 | .idea/httpRequests 10 | # Datasource local storage ignored files 11 | .idea/dataSources 12 | .idea/dataSources.local.xml 13 | fly.toml 14 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 40 | with: 41 | context: . 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /resources/reddit -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine3.18 AS builder 2 | RUN apk add --no-cache make 3 | WORKDIR /app 4 | COPY ./ /app 5 | RUN make build 6 | 7 | FROM alpine:3.18 8 | COPY --from=builder /app/build/rings-backend /usr/bin/rings-backend 9 | ENTRYPOINT ["/usr/bin/rings-backend"] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=rings-backend 2 | IMAGE_NAME=ghcr.io/denysvitali/rings-social-backend 3 | IMAGE_TAG=latest 4 | 5 | build: 6 | CGO_ENABLED=0 go build -o build/$(BINARY_NAME) ./ 7 | 8 | 9 | docker-build: 10 | docker build \ 11 | -t "$(IMAGE_NAME):$(IMAGE_TAG)" \ 12 | . 13 | 14 | docker-push: 15 | docker push "$(IMAGE_NAME):$(IMAGE_TAG)" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rings-social-backend 2 | 3 | This is the backend code for [rings.social](https://rings.social) a content-voting platform that is Reddit-API compatible. 4 | 5 | ## Requirements 6 | 7 | - Go 8 | - `make` 9 | - Docker 10 | - `docker-compose` 11 | - Auth0 Application (auth can't be disabled for now) 12 | 13 | ## Getting started 14 | 15 | ``` 16 | docker-compose up -d 17 | make build 18 | 19 | # Config 20 | export DATABASE_URL=postgresql://ring:ring@localhost:5432/ring 21 | export AUTH0_DOMAIN=your-domain.auth0.com 22 | export AUTH0_CLIENT_ID=xyz 23 | read -s -r AUTH0_CLIENT_SECRET # type the secret and press ENTER 24 | export AUTH0_CLIENT_SECRET 25 | 26 | ./build/rings-backend 27 | ``` 28 | 29 | Congrats! The backend should be up and running on the displayed address. 30 | To listen on `0.0.0.0` just pass the `-l` argument (for example `-l 0.0.0.0:8080`) 31 | 32 | ## Testing it 33 | 34 | Choose one of the [routes](./pkg/routes.go) or 35 | modify a Reddit application to point to your backend. 36 | 37 | Alternatively, use the [Rings frontend](https://github.com/rings-social/frontend) to 38 | have the full [rings.social](https://rings.social) experience. 39 | 40 | ## Reddit compatible API 41 | 42 | We're planning to have a Reddit compatibility layer to allow the existing apps (e.g: Sync, RIF, Apollo, ...) 43 | to effortlessly migrate to Rings. 44 | 45 | 46 | ## Contributions 47 | 48 | Contributions are welcome. Help us shape the future by sending your PRs! 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | postgres: 4 | image: postgres:15-alpine 5 | container_name: postgres 6 | restart: always 7 | environment: 8 | POSTGRES_USER: ring 9 | POSTGRES_PASSWORD: ring 10 | POSTGRES_DB: ring 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres-data:/var/lib/postgresql/data 15 | volumes: 16 | postgres-data: 17 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for rings-backend on 2023-06-22T08:06:45+02:00 2 | 3 | app = "rings-backend" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | mounts = [] 7 | primary_region = "ams" 8 | processes = [] 9 | 10 | [env] 11 | RINGS_LISTEN_ADDR = "0.0.0.0:8080" 12 | RINGS_BASE_URL = "https://rings.social" 13 | AUTH0_CLIENT_ID = "xTK0dWY5c34jnyfRtOd8LY7fQdmdzf1T" 14 | AUTH0_DOMAIN = "rings.eu.auth0.com" 15 | 16 | [[services]] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | [services.concurrency] 21 | hard_limit = 25 22 | soft_limit = 20 23 | type = "connections" 24 | 25 | [[services.ports]] 26 | force_https = true 27 | handlers = ["http"] 28 | port = 80 29 | 30 | [[services.ports]] 31 | handlers = ["tls", "http"] 32 | port = 443 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module backend 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.4.3 7 | github.com/coreos/go-oidc/v3 v3.6.0 8 | github.com/gin-contrib/cors v1.4.0 9 | github.com/gin-gonic/gin v1.9.1 10 | github.com/sirupsen/logrus v1.9.3 11 | golang.org/x/oauth2 v0.6.0 12 | gorm.io/driver/postgres v1.5.2 13 | gorm.io/gorm v1.25.1 14 | ) 15 | 16 | require ( 17 | github.com/alexflint/go-scalar v1.1.0 // indirect 18 | github.com/bytedance/sonic v1.9.1 // indirect 19 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 21 | github.com/gin-contrib/sse v0.1.0 // indirect 22 | github.com/go-jose/go-jose/v3 v3.0.0 // indirect 23 | github.com/go-playground/locales v0.14.1 // indirect 24 | github.com/go-playground/universal-translator v0.18.1 // indirect 25 | github.com/go-playground/validator/v10 v10.14.1 // indirect 26 | github.com/goccy/go-json v0.10.2 // indirect 27 | github.com/golang/protobuf v1.5.2 // indirect 28 | github.com/jackc/pgpassfile v1.0.0 // indirect 29 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 30 | github.com/jackc/pgx/v5 v5.3.1 // indirect 31 | github.com/jinzhu/inflection v1.0.0 // indirect 32 | github.com/jinzhu/now v1.1.5 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 35 | github.com/leodido/go-urn v1.2.4 // indirect 36 | github.com/mattn/go-isatty v0.0.19 // indirect 37 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 38 | github.com/modern-go/reflect2 v1.0.2 // indirect 39 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 40 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 41 | github.com/ugorji/go/codec v1.2.11 // indirect 42 | golang.org/x/arch v0.3.0 // indirect 43 | golang.org/x/crypto v0.9.0 // indirect 44 | golang.org/x/net v0.10.0 // indirect 45 | golang.org/x/sys v0.9.0 // indirect 46 | golang.org/x/text v0.10.0 // indirect 47 | google.golang.org/appengine v1.6.7 // indirect 48 | google.golang.org/protobuf v1.30.0 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= 2 | github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= 3 | github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= 4 | github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= 5 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 6 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 7 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 8 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 9 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 10 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 11 | github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= 12 | github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 18 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 19 | github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g= 20 | github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs= 21 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 22 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 23 | github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= 24 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 25 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 26 | github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= 27 | github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= 28 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 29 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 30 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 31 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 32 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 33 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= 34 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 35 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 36 | github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= 37 | github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= 38 | github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 39 | github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 40 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 41 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 42 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 43 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 44 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 45 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 46 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 47 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 49 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 50 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 51 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 52 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 53 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 54 | github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= 55 | github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 56 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 57 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 58 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 59 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 60 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 61 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 62 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 63 | github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= 64 | github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 65 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 66 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 67 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 68 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 69 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 70 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 71 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 72 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 73 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= 74 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 75 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 76 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 77 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 78 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 79 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 81 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 82 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 83 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 84 | github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= 85 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 86 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 87 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 88 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 91 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= 92 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 93 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 94 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 95 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 96 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 97 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 98 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 99 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 100 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 101 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 102 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 103 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 104 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 105 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 106 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 107 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 108 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 109 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 110 | github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= 111 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 112 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 113 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 114 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 115 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 116 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 117 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 118 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 119 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 120 | golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= 121 | golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 122 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 123 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 124 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 125 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= 126 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 127 | golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= 128 | golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= 129 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 130 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 138 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 139 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 141 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 142 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 143 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 144 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 145 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 146 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 147 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 148 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 150 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 151 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 152 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 153 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 154 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 155 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 156 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 157 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 158 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 159 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 160 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 161 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 162 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 163 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 164 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 165 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 166 | gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= 167 | gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= 168 | gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64= 169 | gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 170 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 171 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | server "backend/pkg" 5 | "github.com/alexflint/go-arg" 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | var args struct { 10 | Debug bool `arg:"-D,--debug,env:RINGS_DEBUG" help:"enable debug mode"` 11 | ListenAddr string `arg:"-l,--listen,env:RINGS_LISTEN_ADDR" default:"127.0.0.1:8081" help:"address to listen on"` 12 | DatabaseUrl string `arg:"--database-url,env:DATABASE_URL,required" help:"Database URL"` 13 | BaseUrl string `arg:"--base-url,env:RINGS_BASE_URL,required" help:"Base URL for the main website"` 14 | 15 | Auth0Domain string `arg:"--auth0-domain,env:AUTH0_DOMAIN,required" help:"Auth0 domain"` 16 | Auth0ClientId string `arg:"--auth0-client-id,env:AUTH0_CLIENT_ID,required" help:"Auth0 client ID"` 17 | Auth0ClientSecret string `arg:"--auth0-client-secret,env:AUTH0_CLIENT_SECRET,required" help:"Auth0 client secret"` 18 | } 19 | 20 | var logger = logrus.New() 21 | 22 | func main() { 23 | runMain() 24 | } 25 | 26 | func runMain() { 27 | arg.MustParse(&args) 28 | 29 | s, err := server.New(args.DatabaseUrl, &server.Auth0Config{ 30 | Domain: args.Auth0Domain, 31 | ClientId: args.Auth0ClientId, 32 | ClientSecret: args.Auth0ClientSecret, 33 | }, 34 | args.BaseUrl) 35 | if err != nil { 36 | logger.Fatal(err) 37 | } 38 | 39 | err = s.Run(args.ListenAddr) 40 | if err != nil { 41 | logger.Fatal(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/action_comment.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "backend/pkg/request" 6 | "errors" 7 | "fmt" 8 | "github.com/gin-gonic/gin" 9 | "gorm.io/gorm" 10 | "net/http" 11 | "strconv" 12 | ) 13 | 14 | func (s *Server) getComments(c *gin.Context) { 15 | postId, done := parsePostId(c) 16 | if done { 17 | return 18 | } 19 | 20 | var parentId *uint 21 | parentIdParam := c.Query("parent_id") 22 | if parentIdParam != "" { 23 | parentIdInt, err := strconv.Atoi(parentIdParam) 24 | if err != nil { 25 | c.JSON(400, gin.H{"error": "parent_id must be a number"}) 26 | return 27 | } 28 | if parentIdInt < 0 { 29 | c.JSON(400, gin.H{"error": "parent_id must be a positive number"}) 30 | return 31 | } 32 | 33 | parentIdUint := uint(parentIdInt) 34 | parentId = &parentIdUint 35 | } 36 | 37 | commentActions := map[uint]models.CommentAction{} 38 | if s.hasIdToken(c) { 39 | idToken, done := s.idToken(c) 40 | if done { 41 | return 42 | } 43 | 44 | username, err := s.usernameForIdToken(idToken) 45 | if err == nil { 46 | commentActionsArr := s.repoCommentActions(username, postId) 47 | for _, v := range commentActionsArr { 48 | commentActions[v.CommentId] = v 49 | } 50 | } 51 | } 52 | 53 | comments, done := s.retrieveComments(c, uint(postId), parentId, commentActions) 54 | if done { 55 | return 56 | } 57 | 58 | c.JSON(200, comments) 59 | } 60 | func (s *Server) postComment(c *gin.Context) { 61 | postId, done := parsePostId(c) 62 | if done { 63 | return 64 | } 65 | 66 | // Check if user is authenticated 67 | idToken, done := s.idToken(c) 68 | if done { 69 | return 70 | } 71 | 72 | // Get user id by idToken 73 | username, err := s.usernameForIdToken(idToken) 74 | if err != nil { 75 | c.JSON(http.StatusForbidden, gin.H{ 76 | "error": "invalid id token", 77 | }) 78 | return 79 | } 80 | 81 | var commentRequest request.Comment 82 | err = c.BindJSON(&commentRequest) 83 | if err != nil { 84 | c.JSON(http.StatusBadRequest, gin.H{ 85 | "error": "invalid request body", 86 | }) 87 | return 88 | } 89 | 90 | comment, err := s.addComment(uint(postId), username, commentRequest) 91 | if err != nil { 92 | s.logger.Errorf("unable to add comment: %v", err) 93 | internalServerError(c) 94 | return 95 | } 96 | 97 | c.JSON(200, comment) 98 | } 99 | 100 | func (s *Server) deleteComment(c *gin.Context) { 101 | postId, done := parsePostId(c) 102 | if done { 103 | return 104 | } 105 | 106 | commentId, done := parseId(c, "commentId") 107 | if done { 108 | return 109 | } 110 | 111 | // Check if user is authenticated 112 | idToken, done := s.idToken(c) 113 | if done { 114 | return 115 | } 116 | 117 | // Get user by idtoken 118 | username, err := s.usernameForIdToken(idToken) 119 | if err != nil { 120 | c.JSON(http.StatusBadRequest, gin.H{ 121 | "error": "user not registered", 122 | }) 123 | return 124 | } 125 | 126 | // Get comment 127 | comment, err := s.repoComment(uint(commentId)) 128 | if err != nil { 129 | if errors.Is(err, gorm.ErrRecordNotFound) { 130 | c.JSON(http.StatusNotFound, gin.H{ 131 | "error": "comment not found", 132 | }) 133 | return 134 | } 135 | } 136 | 137 | if comment.PostId != uint(postId) { 138 | c.JSON(http.StatusBadRequest, 139 | gin.H{ 140 | "error": "comment doesn't belong to this post", 141 | }, 142 | ) 143 | return 144 | } 145 | 146 | if comment.AuthorUsername != username { 147 | // Check if the user is an admin 148 | if !s.isAdmin(username) { 149 | s.logger.Errorf("user %s tried to delete comment %d which he doesn't own", username, commentId) 150 | c.JSON(http.StatusForbidden, 151 | gin.H{"error": "you can't delete this comment"}, 152 | ) 153 | return 154 | } 155 | } 156 | 157 | if comment.DeletedAt != nil { 158 | // Comment already deleted 159 | c.JSON(http.StatusBadRequest, 160 | gin.H{"error": "comment already deleted"}) 161 | return 162 | } 163 | 164 | err = s.repoDeleteComment(uint(commentId)) 165 | if err != nil { 166 | s.logger.Errorf("unable to delete comment: %v", err) 167 | internalServerError(c) 168 | return 169 | } 170 | 171 | c.JSON(http.StatusOK, gin.H{}) 172 | } 173 | 174 | func (s *Server) voteAction(c *gin.Context, action models.VoteAction) { 175 | // Needs to be logged in to proceed 176 | idToken, done := s.idToken(c) 177 | if done { 178 | return 179 | } 180 | username, err := s.usernameForIdToken(idToken) 181 | if err != nil { 182 | c.JSON(http.StatusBadRequest, gin.H{ 183 | "error": "user not registered", 184 | }) 185 | return 186 | } 187 | 188 | // Get comment 189 | commentId, done := parseId(c, "commentId") 190 | if done { 191 | return 192 | } 193 | comment, err := s.repoComment(uint(commentId)) 194 | if err != nil { 195 | if errors.Is(err, gorm.ErrRecordNotFound) { 196 | c.JSON(http.StatusNotFound, gin.H{ 197 | "error": "comment not found", 198 | }) 199 | return 200 | } 201 | internalServerError(c) 202 | return 203 | } 204 | 205 | if comment.DeletedAt != nil { 206 | // Comment already deleted 207 | c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("deleted comments cannot be %s", action)}) 208 | return 209 | } 210 | 211 | voteAction, addScore, err := s.repoCommentVoteAction(uint(commentId), username, action) 212 | if err != nil { 213 | // Unable to upvote, bad request: 214 | c.JSON(http.StatusBadRequest, gin.H{ 215 | "error": fmt.Sprintf("unable to %s comment", action), 216 | }) 217 | return 218 | } 219 | 220 | err = s.repoIncreaseCommentScore(uint(commentId), addScore) 221 | if err != nil { 222 | s.logger.Errorf("unable to change comment score: %v", err) 223 | internalServerError(c) 224 | return 225 | } 226 | 227 | c.JSON(http.StatusOK, voteAction) 228 | } 229 | 230 | func (s *Server) upvoteComment(c *gin.Context) { 231 | s.voteAction(c, models.ActionUpvote) 232 | } 233 | func (s *Server) downvoteComment(c *gin.Context) { 234 | s.voteAction(c, models.ActionDownvote) 235 | } 236 | 237 | // routeGetRecentComments returns the most recent comments 238 | func (s *Server) routeGetRecentComments(c *gin.Context) { 239 | afterParam := c.Query("after") 240 | var after *uint64 241 | if afterParam != "" { 242 | // Parse after param 243 | afterV, err := strconv.ParseUint(afterParam, 10, 64) 244 | if err != nil { 245 | c.JSON(http.StatusBadRequest, gin.H{ 246 | "error": "invalid after param", 247 | }) 248 | return 249 | } 250 | after = &afterV 251 | } 252 | 253 | // count comments 254 | countComments, err := s.repoCountComments() 255 | if err != nil { 256 | s.logger.Errorf("unable to count comments: %v", err) 257 | internalServerError(c) 258 | return 259 | } 260 | 261 | comments, err := s.repoRecentComments(after) 262 | if err != nil { 263 | s.logger.Errorf("unable to retrieve recent comments: %v", err) 264 | internalServerError(c) 265 | return 266 | } 267 | 268 | paginatedAfter := "" 269 | if len(comments) != 0 { 270 | paginatedAfter = fmt.Sprintf("%d", comments[len(comments)-1].ID) 271 | } 272 | returnPaginated(c, paginatedAfter, comments, countComments) 273 | 274 | } 275 | -------------------------------------------------------------------------------- /pkg/auth.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | func (s *Server) authenticatedUser(c *gin.Context) { 9 | if !s.hasIdToken(c) { 10 | c.JSON(http.StatusForbidden, gin.H{ 11 | "error": "Not authenticated", 12 | }) 13 | return 14 | } 15 | 16 | idToken, done := s.idToken(c) 17 | if done { 18 | return 19 | } 20 | 21 | // Make sure the user exists in the database 22 | username, err := s.usernameForIdToken(idToken) 23 | if err != nil { 24 | c.JSON(http.StatusForbidden, gin.H{ 25 | "error": "Not authenticated", 26 | }) 27 | return 28 | } 29 | 30 | c.Set("username", username) 31 | } 32 | 33 | // maybeAuthenticatedUser is like authenticatedUser, but it doesn't 34 | // require the user to be authenticated. If the user is authenticated, it 35 | // sets the "username" context variable. 36 | func (s *Server) maybeAuthenticatedUser(c *gin.Context) { 37 | if !s.hasIdToken(c) { 38 | return 39 | } 40 | 41 | idToken, done := s.idToken(c) 42 | if done { 43 | return 44 | } 45 | 46 | username, err := s.usernameForIdToken(idToken) 47 | if err != nil { 48 | return 49 | } 50 | 51 | c.Set("username", username) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/cleanup_users.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/models" 4 | 5 | // cleanupUsers removes sensitive information from a list of users. 6 | func cleanupUsers(users []models.User) []models.User { 7 | for i := range users { 8 | users[i].AuthSubject = nil 9 | } 10 | return users 11 | } 12 | -------------------------------------------------------------------------------- /pkg/convert_response.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "backend/pkg/response" 6 | ) 7 | 8 | func convertResponsePosts(posts []models.Post, r *models.Ring) []response.Post { 9 | var responsePosts []response.Post 10 | for _, p := range posts { 11 | responsePost := response.Post{ 12 | ID: int(p.ID), 13 | CreatedAt: p.CreatedAt, 14 | RingName: p.RingName, 15 | AuthorUsername: p.AuthorUsername, 16 | Author: p.Author, 17 | Title: p.Title, 18 | Body: p.Body, 19 | Link: p.Link, 20 | Domain: p.Domain, 21 | Score: p.Score, 22 | CommentsCount: p.CommentsCount, 23 | Ups: p.Ups, 24 | Downs: p.Downs, 25 | Nsfw: p.Nsfw, 26 | VotedUp: p.VotedUp, 27 | VotedDown: p.VotedDown, 28 | } 29 | if p.Ring != nil { 30 | responsePost.RingColor = p.Ring.PrimaryColor 31 | } else { 32 | responsePost.RingColor = r.PrimaryColor 33 | } 34 | responsePosts = append(responsePosts, responsePost) 35 | } 36 | return responsePosts 37 | } 38 | -------------------------------------------------------------------------------- /pkg/error.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | const ErrInvalidPostRequest = "invalid post request" 4 | const ErrRingDoesNotExist = "ring does not exist" 5 | const ErrUnableToGetPost = "unable to get post" 6 | const ErrUnableToGetVote = "unable to get vote" 7 | const ErrUnableToSaveVote = "unable to save vote" 8 | const ErrUnableToIncreasePostScore = "unable to increase post score" 9 | const ErrUnableToCreateVote = "unable to create vote" 10 | const ErrUnableToVoteUserAlreadyVoted = "unable to vote, user already voted" 11 | -------------------------------------------------------------------------------- /pkg/fill_test_data.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "gorm.io/gorm/clause" 6 | "time" 7 | ) 8 | 9 | func (s *Server) fillTestData() { 10 | slackLink := "https://join.slack.com/t/ringssocial/shared_invite/zt-1xyl4xys4-fhjfig1CqmALL~cWqiIGcQ" 11 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&models.Ring{ 12 | Name: "news", 13 | Description: "News from around the world", 14 | DisplayName: "News", 15 | Title: "Title", 16 | Subscribers: 1000, 17 | CreatedAt: time.Now(), 18 | PrimaryColor: "#FFC107", 19 | }) 20 | 21 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&models.Ring{ 22 | Name: "popular", 23 | Description: "Popular Posts", 24 | CreatedAt: time.Now(), 25 | Subscribers: 2000, 26 | PrimaryColor: "#49545f", 27 | }) 28 | 29 | s.fillTestUsers() 30 | 31 | theGuardianLink := "https://www.theguardian.com/us-news/2023/jun/12/harmeet-dhillon-republican-lawyer-rnc-fox-news" 32 | theGuardianDomain := "theguardian.com" 33 | 34 | popularPost := models.Post{ 35 | Model: models.Model{ID: 1, CreatedAt: time.Now()}, 36 | AuthorUsername: "random_dude", 37 | RingName: "popular", 38 | Title: "This is a popular post", 39 | Link: &theGuardianLink, 40 | Domain: &theGuardianDomain, 41 | Score: 1233, 42 | CommentsCount: 14, 43 | Nsfw: false, 44 | } 45 | 46 | textPost := models.Post{ 47 | Model: models.Model{ID: 2, CreatedAt: time.Now()}, 48 | AuthorUsername: "john_doe", 49 | RingName: "popular", 50 | Title: "This is a text post", 51 | Body: "abc", 52 | Score: 4, 53 | CommentsCount: 0, 54 | Nsfw: false, 55 | } 56 | 57 | nsfwPost := models.Post{ 58 | Model: models.Model{ID: 3, CreatedAt: time.Now()}, 59 | AuthorUsername: "john_doe", 60 | RingName: "news", 61 | Title: "This is a NSFW post", 62 | Body: "NSFW content goes here", 63 | Score: 1, 64 | Ups: 1, 65 | Downs: 0, 66 | CommentsCount: 0, 67 | Nsfw: true, 68 | } 69 | 70 | newsPost := models.Post{ 71 | Model: models.Model{ID: 4, CreatedAt: time.Now()}, 72 | AuthorUsername: "random_dude", 73 | RingName: "news", 74 | Title: "Republican official appears to have moved $0.3m from nonprofit to own law firm", 75 | Link: &theGuardianLink, 76 | Domain: &theGuardianDomain, 77 | Score: 1302, 78 | Nsfw: false, 79 | } 80 | 81 | introductionPost := models.Post{ 82 | Model: models.Model{ID: 5, CreatedAt: time.Now()}, 83 | AuthorUsername: "denvit", 84 | RingName: "popular", 85 | Title: "Welcome to the Rings.social", 86 | Link: createRefString("/about"), 87 | Domain: createRefString("rings.social"), 88 | Score: 10, 89 | CommentsCount: 1, 90 | Nsfw: false, 91 | } 92 | 93 | notVisitedPost := models.Post{ 94 | Model: models.Model{ID: 6, CreatedAt: time.Now()}, 95 | AuthorUsername: "denvit", 96 | RingName: "popular", 97 | Title: "Do not click me", 98 | Link: createRefString("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), 99 | Domain: createRefString("youtube.com"), 100 | Score: -1, 101 | CommentsCount: 0, 102 | Nsfw: false, 103 | } 104 | 105 | err := s.createOrUpdatePosts([]models.Post{popularPost, 106 | textPost, 107 | nsfwPost, 108 | newsPost, 109 | introductionPost, 110 | notVisitedPost, 111 | }) 112 | 113 | if err != nil { 114 | s.logger.Fatalf("failed to create test posts: %v", err) 115 | } 116 | 117 | comment1 := models.Comment{ 118 | Model: models.Model{ID: 1, CreatedAt: time.Now()}, 119 | AuthorUsername: "john_doe", 120 | PostId: popularPost.ID, 121 | Body: "Thanks for sharing!", 122 | Score: 99, 123 | Ups: 100, 124 | Downs: 1, 125 | } 126 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&comment1) 127 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&models.Comment{ 128 | Model: models.Model{ID: 2, CreatedAt: time.Now()}, 129 | AuthorUsername: "random_dude", 130 | PostId: popularPost.ID, 131 | ParentId: &comment1.ID, 132 | Body: "You're welcome :)", 133 | Score: 32, 134 | Ups: 42, 135 | Downs: 10, 136 | }) 137 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&models.Comment{ 138 | Model: models.Model{ID: 3, CreatedAt: time.Now()}, 139 | AuthorUsername: "john_doe", 140 | PostId: popularPost.ID, 141 | Body: "This comment doesn't have any replies", 142 | Score: -1, 143 | Ups: 0, 144 | Downs: 1, 145 | }) 146 | 147 | s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&models.Comment{ 148 | Model: models.Model{ID: 4, CreatedAt: time.Now()}, 149 | AuthorUsername: "denvit", 150 | PostId: introductionPost.ID, 151 | Ups: 5, 152 | Downs: 0, 153 | Score: 5, 154 | Body: "To learn more about Rings, check out the [about page](/about). \n\n" + 155 | "If you want to contribute to the project, check out [GitHub organization](https://github.com/rings-social)" + 156 | " and [join our Slack channel](" + slackLink + "). \n\n" + 157 | "By the way, did you know that our comments support markdown? **Bold**, _italic_, `preformat`\n" + 158 | "\n" + 159 | "```js\n" + 160 | "console.log('hello')\n" + 161 | "```\n", 162 | }) 163 | 164 | s.db.Exec("ALTER SEQUENCE comments_id_seq RESTART WITH 5;") 165 | } 166 | 167 | func createRefString(s string) *string { 168 | return &s 169 | } 170 | 171 | func (s *Server) createOrUpdatePosts(posts []models.Post) error { 172 | for _, p := range posts { 173 | tx := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&p) 174 | if tx.Error != nil { 175 | return tx.Error 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | func (s *Server) fillTestUsers() { 182 | authSubj := "auth0|6498715e20e9a839adbfee8e" 183 | users := []models.User{ 184 | { 185 | Username: "random_dude", 186 | DisplayName: "Random Dude", 187 | // ProfilePicture: createRefString("https://images.unsplash.com/photo-1596075780750-81249df16d19?fit=crop&w=200&q=80"), 188 | ProfilePicture: nil, 189 | SocialLinks: []models.SocialLink{ 190 | { 191 | Platform: "twitter", 192 | Url: "https://twitter.com/random_dude", 193 | }, 194 | }, 195 | CreatedAt: time.Now(), 196 | }, 197 | { 198 | Username: "john_doe", 199 | DisplayName: "John Doe", 200 | ProfilePicture: createRefString("https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?fit=crop&w=200&q=80"), 201 | SocialLinks: []models.SocialLink{ 202 | { 203 | Platform: "twitter", 204 | Url: "https://twitter.com/john_doe", 205 | }, 206 | }, 207 | CreatedAt: time.Now(), 208 | }, 209 | { 210 | Username: "denvit", 211 | DisplayName: "Denys Vitali", 212 | Admin: true, 213 | ProfilePicture: createRefString("https://pbs.twimg.com/profile_images/1441455322455949319/_0xwiskP_400x400.jpg"), 214 | SocialLinks: []models.SocialLink{ 215 | { 216 | Platform: "twitter", 217 | Url: "https://twitter.com/DenysVitali", 218 | }, 219 | }, 220 | AuthSubject: &authSubj, 221 | Badges: []models.Badge{ 222 | { 223 | Id: "admin", 224 | BackgroundColor: "#d70000", 225 | TextColor: "#ffffff", 226 | }, 227 | { 228 | Id: "supporter", 229 | BackgroundColor: "#ffde3f", 230 | TextColor: "#895900", 231 | }, 232 | }, 233 | CreatedAt: time.Now(), 234 | }, 235 | } 236 | 237 | for _, u := range users { 238 | tx := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&u) 239 | if tx.Error != nil { 240 | s.logger.Fatalf("failed to create test users: %v", tx.Error) 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /pkg/mask.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/models" 4 | 5 | // maskDeletedComments 6 | func maskDeletedComments(comments []models.Comment) []models.Comment { 7 | var maskedComments []models.Comment 8 | for _, v := range comments { 9 | if v.DeletedAt != nil { 10 | v.Body = "[deleted]" 11 | } 12 | maskedComments = append(maskedComments, v) 13 | } 14 | return maskedComments 15 | } 16 | -------------------------------------------------------------------------------- /pkg/models/badge.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Badge struct { 4 | // A badge is a small icon that appears next to a user's name. 5 | Model 6 | Id string `json:"id" gorm:"primaryKey"` 7 | BackgroundColor string `json:"backgroundColor"` 8 | TextColor string `json:"textColor"` 9 | } 10 | -------------------------------------------------------------------------------- /pkg/models/comment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Comment struct { 4 | // A comment is a reply to a post. 5 | Model 6 | Post *Post `json:"post,omitempty" gorm:"foreignKey:PostId"` 7 | PostId uint `json:"post_id"` 8 | Parent *Comment `json:"parent,omitempty" gorm:"foreignKey:ParentId"` 9 | ParentId *uint `json:"parent_id"` 10 | 11 | // The author of the comment. 12 | AuthorUsername string `json:"author_id"` 13 | Author User `json:"author" gorm:"foreignKey:AuthorUsername;references:Username"` 14 | 15 | // The comment's content. 16 | Body string `json:"body"` 17 | Ups uint `json:"ups" gorm:"default:0"` 18 | Downs uint `json:"downs"` 19 | Score int `json:"score"` 20 | 21 | Depth int `json:"depth" gorm:"-"` 22 | Replies []Comment `json:"replies" gorm:"-"` 23 | 24 | // Dynamic 25 | VotedUp bool `json:"votedUp" gorm:"-"` 26 | VotedDown bool `json:"votedDown" gorm:"-"` 27 | } 28 | -------------------------------------------------------------------------------- /pkg/models/comment_action.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type VoteAction = string 6 | 7 | const ActionUpvote VoteAction = "upvote" 8 | const ActionDownvote VoteAction = "downvote" 9 | 10 | type CommentAction struct { 11 | Username string `json:"username" gorm:"primaryKey"` 12 | User User `json:"user,omitempty" gorm:"foreignKey:Username"` 13 | Comment Comment `json:"comment,omitempty"` 14 | CommentId uint `json:"comment_id" gorm:"primaryKey"` 15 | Action string `json:"action" gorm:"type:comment_action;index"` 16 | CreatedAt time.Time 17 | } 18 | -------------------------------------------------------------------------------- /pkg/models/model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Model struct { 8 | ID uint `json:"id" gorm:"primarykey;autoIncrmeent:true"` 9 | CreatedAt time.Time `json:"createdAt"` 10 | UpdatedAt time.Time `json:"updatedAt"` 11 | DeletedAt *time.Time `json:"deletedAt,omitempty" gorm:"index"` 12 | } 13 | -------------------------------------------------------------------------------- /pkg/models/post.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Post struct { 4 | // A post is a message in a ring. 5 | Model 6 | RingName string `json:"ringName" gorm:"index"` 7 | Ring *Ring `json:"ring,omitempty" gorm:"foreignKey:RingName;references:Name"` 8 | AuthorUsername string `json:"author_username" gorm:"index"` 9 | Author *User `json:"author,omitempty" gorm:"foreignKey:AuthorUsername;references:Username"` 10 | Title string `json:"title"` 11 | Body string `json:"body,omitempty"` 12 | Link *string `json:"link"` 13 | Domain *string `json:"domain" gorm:"index"` 14 | Score int `json:"score"` 15 | CommentsCount int `json:"commentsCount"` 16 | Ups int `json:"ups"` 17 | Downs int `json:"downs"` 18 | Nsfw bool `json:"nsfw"` 19 | 20 | VotedUp bool `json:"votedUp" gorm:"-"` 21 | VotedDown bool `json:"votedDown" gorm:"-"` 22 | } 23 | -------------------------------------------------------------------------------- /pkg/models/post_action.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type PostAction struct { 6 | Username string `json:"username" gorm:"primaryKey"` 7 | User User `json:"user,omitempty" gorm:"foreignKey:Username"` 8 | Post Post `json:"post,omitempty"` 9 | PostId uint `json:"post_id" gorm:"primaryKey"` 10 | Action string `json:"action" gorm:"type:post_action;index"` 11 | CreatedAt time.Time 12 | } 13 | -------------------------------------------------------------------------------- /pkg/models/ring.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Ring struct { 8 | // A ring is a community: a collection of posts. 9 | Name string `json:"name" gorm:"primaryKey"` 10 | Title string `json:"title"` 11 | DisplayName string `json:"displayName"` 12 | Description string `json:"description"` 13 | Posts []Post `json:"posts,omitempty" gorm:"foreignKey:RingName;references:Name"` 14 | CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` 15 | DeletedAt *time.Time `json:"deletedAt,omitempty"` 16 | Nsfw bool `json:"nsfw"` 17 | PrimaryColor string `json:"primaryColor"` 18 | OwnerUsername string `json:"ownerUsername"` 19 | Owner *User `json:"owner,omitempty" gorm:"foreignKey:OwnerUsername;references:Username"` 20 | Subscribers uint64 `json:"subscribers"` 21 | } 22 | -------------------------------------------------------------------------------- /pkg/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type SocialLink struct { 8 | // A social link is a link to a user's profile on another site. 9 | Username string `json:"username" gorm:"primaryKey"` 10 | Platform string `json:"platform" gorm:"primaryKey"` 11 | Url string `json:"url"` 12 | } 13 | 14 | type User struct { 15 | // A user is a person who can post to rings. 16 | Username string `json:"username" gorm:"primaryKey"` 17 | DisplayName string `json:"displayName"` 18 | ProfilePicture *string `json:"profilePicture"` 19 | SocialLinks []SocialLink `json:"socialLinks,omitempty" gorm:"foreignKey:Username;references:Username"` 20 | CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` 21 | DeletedAt *time.Time `json:"deletedAt,omitempty"` 22 | Posts []Post `json:"posts,omitempty" gorm:"foreignKey:AuthorUsername;references:Username"` 23 | 24 | // A user might have multiple badges 25 | Badges []Badge `json:"badges,omitempty" gorm:"many2many:user_badges;"` 26 | 27 | AuthSubject *string `json:"-" gorm:"uniqueIndex"` 28 | Admin bool `json:"admin"` 29 | } 30 | -------------------------------------------------------------------------------- /pkg/pagination.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "strconv" 6 | ) 7 | 8 | type Pagination struct { 9 | After string 10 | Limit uint64 11 | } 12 | 13 | func (s *Server) getPagination(c *gin.Context) (*Pagination, bool) { 14 | // Pagination is done using the following query parameters: 15 | // - after: the cursor to start fetching results from 16 | // - limit: the maximum number of results to return 17 | 18 | after := c.Query("after") 19 | limit := c.Query("limit") 20 | 21 | var parsedLimit uint64 = 25 22 | 23 | if limit != "" { 24 | var err error 25 | parsedLimit, err = strconv.ParseUint(limit, 10, 64) 26 | if err != nil { 27 | s.logger.Warnf("unable to parse limit %s: %v", limit, err) 28 | c.JSON(400, gin.H{"error": "limit must be a number"}) 29 | return nil, true 30 | } 31 | 32 | if parsedLimit > 100 { 33 | c.JSON(400, gin.H{"error": "limit must be less than or equal to 100"}) 34 | return nil, true 35 | } 36 | } 37 | 38 | return &Pagination{ 39 | After: after, 40 | Limit: parsedLimit, 41 | }, false 42 | 43 | } 44 | -------------------------------------------------------------------------------- /pkg/platform/auth/auth.go: -------------------------------------------------------------------------------- 1 | package authenticator 2 | 3 | import ( 4 | "context" 5 | "github.com/coreos/go-oidc/v3/oidc" 6 | "golang.org/x/oauth2" 7 | ) 8 | 9 | // Authenticator is used to authenticate our users. 10 | type Authenticator struct { 11 | *oidc.Provider 12 | oauth2.Config 13 | } 14 | 15 | // New instantiates the *Authenticator. 16 | func New(domain string, clientId string, clientSecret string) (*Authenticator, error) { 17 | provider, err := oidc.NewProvider( 18 | context.Background(), 19 | "https://"+domain+"/", 20 | ) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | conf := oauth2.Config{ 26 | ClientID: clientId, 27 | ClientSecret: clientSecret, 28 | Endpoint: provider.Endpoint(), 29 | Scopes: []string{oidc.ScopeOpenID, "profile"}, 30 | } 31 | 32 | return &Authenticator{ 33 | Provider: provider, 34 | Config: conf, 35 | }, nil 36 | } 37 | 38 | // VerifyToken verifies that the passed token is a valid *oidc.IDToken. 39 | func (a *Authenticator) VerifyToken(ctx context.Context, token string) (*oidc.IDToken, error) { 40 | oidcConfig := &oidc.Config{ 41 | ClientID: a.ClientID, 42 | } 43 | 44 | return a.Verifier(oidcConfig).Verify(ctx, token) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/posts_create.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/request" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func (s *Server) createPost(c *gin.Context) { 9 | var postCreateReq request.PostCreate 10 | err := c.BindJSON(&postCreateReq) 11 | if err != nil { 12 | s.logger.Warnf("unable to bind json: %v", err) 13 | c.JSON(400, gin.H{"error": "invalid request"}) 14 | return 15 | } 16 | 17 | // Get current user 18 | username := c.GetString("username") 19 | post, err := s.repoCreatePost(postCreateReq, username) 20 | 21 | if err != nil { 22 | if err.Error() == ErrRingDoesNotExist { 23 | c.JSON(400, gin.H{"error": "ring does not exist"}) 24 | return 25 | } 26 | 27 | if err.Error() == ErrInvalidPostRequest { 28 | c.JSON(400, gin.H{"error": "invalid post request"}) 29 | return 30 | } 31 | 32 | s.logger.Errorf("unable to create post: %v", err) 33 | internalServerError(c) 34 | return 35 | } 36 | 37 | c.JSON(200, post) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/rc_comments.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "github.com/gin-gonic/gin" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func (s *Server) getRcComments(c *gin.Context) { 11 | id := c.Param("id") 12 | parts := strings.SplitN(id, ".", 2) 13 | if len(parts) != 2 || parts[1] != "json" { 14 | badRequest(c) 15 | return 16 | } 17 | postId, err := strconv.ParseInt(parts[0], 10, 64) 18 | if err != nil { 19 | s.logger.Warnf("invalid post id: %s, %v", id, err) 20 | badRequest(c) 21 | return 22 | } 23 | 24 | // Get Post: 25 | post, err := s.repoPost(uint(postId)) 26 | if err != nil { 27 | internalServerError(c) 28 | return 29 | } 30 | 31 | parentIdParam := c.Query("comment") 32 | var parentId *uint 33 | if parentIdParam != "" { 34 | parentIdInt, err := strconv.ParseInt(parentIdParam, 10, 64) 35 | if err != nil { 36 | s.logger.Warnf("invalid parent id: %s, %v", parentIdParam, err) 37 | badRequest(c) 38 | return 39 | } 40 | parentIdUint := uint(parentIdInt) 41 | parentId = &parentIdUint 42 | } 43 | 44 | comments, done := s.retrieveComments(c, uint(postId), parentId, map[uint]models.CommentAction{}) 45 | if done { 46 | return 47 | } 48 | 49 | comments = maskDeletedComments(comments) 50 | 51 | redditComments, err := toRedditComments(post, comments, s.baseUrl) 52 | if err != nil { 53 | internalServerError(c) 54 | return 55 | } 56 | c.JSON(200, redditComments) 57 | } 58 | 59 | func (s *Server) retrieveComments( 60 | c *gin.Context, 61 | postId uint, 62 | parentId *uint, 63 | commentActions map[uint]models.CommentAction, 64 | ) ([]models.Comment, bool) { 65 | 66 | var comments []models.Comment 67 | var err error 68 | if parentId != nil { 69 | comments, err = s.repoGetCommentTree(postId, *parentId) 70 | } else { 71 | comments, err = s.repoGetTopComments(postId) 72 | } 73 | if err != nil { 74 | s.logger.Errorf("unable to get comments for post %d: %v", postId, err) 75 | internalServerError(c) 76 | return nil, true 77 | } 78 | comments = setCommentActions(comments, commentActions) 79 | comments = maskDeletedComments(comments) 80 | 81 | // Create a tree structure 82 | commentTree := map[uint][]models.Comment{} 83 | var topLevelComments []models.Comment 84 | for k, comment := range comments { 85 | if comment.ParentId != nil { 86 | commentTree[*comment.ParentId] = append(commentTree[*comment.ParentId], comments[k]) 87 | } else { 88 | topLevelComments = append(topLevelComments, comments[k]) 89 | } 90 | } 91 | 92 | // Now that we have the relationships, create the array of top level comments (no parent) 93 | for k, comment := range topLevelComments { 94 | topLevelComments[k] = fillChildren(1, &comment, commentTree) 95 | } 96 | 97 | return topLevelComments, false 98 | } 99 | 100 | // fillChildren recursively fills the children with a comment 101 | func fillChildren(depth int, c *models.Comment, tree map[uint][]models.Comment) models.Comment { 102 | c.Depth = depth 103 | children, ok := tree[c.ID] 104 | if !ok { 105 | return *c 106 | } 107 | for k, child := range children { 108 | children[k] = fillChildren(depth+1, &child, tree) 109 | } 110 | c.Replies = children 111 | return *c 112 | } 113 | 114 | func setCommentActions(comments []models.Comment, actions map[uint]models.CommentAction) []models.Comment { 115 | for k, v := range comments { 116 | action, ok := actions[v.ID] 117 | if ok { 118 | switch action.Action { 119 | case models.ActionDownvote: 120 | comments[k].VotedDown = true 121 | case models.ActionUpvote: 122 | comments[k].VotedUp = true 123 | } 124 | } 125 | } 126 | return comments 127 | } 128 | 129 | func setPostActions(posts []models.Post, actions map[uint]models.PostAction) { 130 | for k, v := range posts { 131 | action, ok := actions[v.ID] 132 | if ok { 133 | switch action.Action { 134 | case models.ActionDownvote: 135 | posts[k].VotedDown = true 136 | case models.ActionUpvote: 137 | posts[k].VotedUp = true 138 | } 139 | } 140 | } 141 | } 142 | 143 | func setDepth(comments []models.Comment, i int) { 144 | for k, _ := range comments { 145 | comments[k].Depth = i 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/reddit_compat/comments.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type Comment struct { 4 | AllAwardings []any `json:"all_awardings,nilasempty"` 5 | ApprovedAtUtc any `json:"approved_at_utc,omitempty"` 6 | ApprovedBy any `json:"approved_by,omitempty"` 7 | Archived bool `json:"archived"` 8 | AssociatedAward any `json:"associated_award,omitempty"` 9 | Author string `json:"author,omitempty"` 10 | AuthorFlairBackgroundColor any `json:"author_flair_background_color,omitempty"` 11 | AuthorFlairCssClass any `json:"author_flair_css_class,omitempty"` 12 | AuthorFlairRichtext []any `json:"author_flair_richtext,omitempty"` 13 | AuthorFlairTemplateID any `json:"author_flair_template_id,omitempty"` 14 | AuthorFlairText any `json:"author_flair_text,omitempty"` 15 | AuthorFlairTextColor any `json:"author_flair_text_color,omitempty"` 16 | AuthorFlairType string `json:"author_flair_type,omitempty"` 17 | AuthorFullname string `json:"author_fullname,omitempty"` 18 | AuthorIsBlocked bool `json:"author_is_blocked"` 19 | AuthorPatreonFlair bool `json:"author_patreon_flair"` 20 | AuthorPremium bool `json:"author_premium"` 21 | Awarders []any `json:"awarders,omitempty"` 22 | BannedAtUtc any `json:"banned_at_utc,omitempty"` 23 | BannedBy any `json:"banned_by,omitempty"` 24 | Body string `json:"body,omitempty"` 25 | BodyHtml string `json:"body_html,omitempty"` 26 | CanGild bool `json:"can_gild,omitempty"` 27 | CanModPost bool `json:"can_mod_post"` 28 | Children []string `json:"children,nilasempty"` 29 | Collapsed bool `json:"collapsed"` 30 | CollapsedBecauseCrowdControl any `json:"collapsed_because_crowd_control,omitempty"` 31 | CollapsedReason *string `json:"collapsed_reason"` 32 | CollapsedReasonCode *string `json:"collapsed_reason_code"` 33 | CommentType any `json:"comment_type,omitempty"` 34 | Controversiality int `json:"controversiality,omitempty"` 35 | Count int `json:"count,omitempty"` 36 | Created int `json:"created,omitempty"` 37 | CreatedUtc int `json:"created_utc,omitempty"` 38 | Depth int `json:"depth"` 39 | Distinguished any `json:"distinguished"` 40 | Downs int `json:"downs,omitempty"` 41 | Edited bool `json:"edited"` 42 | Gilded int `json:"gilded,omitempty"` 43 | Gildings *struct{} `json:"gildings,omitempty"` 44 | ID string `json:"id"` 45 | IsSubmitter bool `json:"is_submitter"` 46 | Likes any `json:"likes,omitempty"` 47 | LinkID string `json:"link_id,omitempty"` 48 | Locked bool `json:"locked"` 49 | ModNote any `json:"mod_note,omitempty"` 50 | ModReasonBy any `json:"mod_reason_by,omitempty"` 51 | ModReasonTitle any `json:"mod_reason_title,omitempty"` 52 | ModReports []any `json:"mod_reports"` 53 | Name string `json:"name"` 54 | NoFollow bool `json:"no_follow"` 55 | NumReports any `json:"num_reports,omitempty"` 56 | ParentID string `json:"parent_id"` 57 | Permalink string `json:"permalink,omitempty"` 58 | RemovalReason any `json:"removal_reason,omitempty"` 59 | Replies any `json:"replies"` 60 | ReportReasons any `json:"report_reasons,omitempty"` 61 | Saved bool `json:"saved"` 62 | Score int `json:"score"` 63 | ScoreHidden bool `json:"score_hidden"` 64 | SendReplies bool `json:"send_replies,omitempty"` 65 | Stickied bool `json:"stickied"` 66 | Subreddit string `json:"subreddit,omitempty"` 67 | SubredditID string `json:"subreddit_id,omitempty"` 68 | SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"` 69 | SubredditType string `json:"subreddit_type,omitempty"` 70 | TopAwardedType any `json:"top_awarded_type,omitempty"` 71 | TotalAwardsReceived int `json:"total_awards_received,omitempty"` 72 | TreatmentTags []any `json:"treatment_tags,omitempty"` 73 | UnrepliableReason any `json:"unrepliable_reason,omitempty"` 74 | Ups int `json:"ups,omitempty"` 75 | UserReports []any `json:"user_reports,omitempty"` 76 | } 77 | -------------------------------------------------------------------------------- /pkg/reddit_compat/kind_data.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type KindData[T any] struct { 4 | Kind string `json:"kind"` 5 | Data T `json:"data"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/reddit_compat/listing.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type Listing[T any] struct { 4 | After *string `json:"after"` 5 | Before *string `json:"before"` 6 | Dist int `json:"dist"` 7 | ModHash string `json:"modhash"` 8 | GeoFilter string `json:"geo_filter"` 9 | Children []KindData[T] `json:"children,nilasempty"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/reddit_compat/post.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type Image struct { 4 | Height int `json:"height"` 5 | URL string `json:"url"` 6 | Width int `json:"width"` 7 | } 8 | 9 | type Award struct { 10 | AwardSubType string `json:"award_sub_type"` 11 | AwardType string `json:"award_type"` 12 | AwardingsRequiredToGrantBenefits any `json:"awardings_required_to_grant_benefits"` 13 | CoinPrice int `json:"coin_price"` 14 | CoinReward int `json:"coin_reward"` 15 | Count int `json:"count"` 16 | DaysOfDripExtension *int `json:"days_of_drip_extension"` 17 | DaysOfPremium *int `json:"days_of_premium"` 18 | Description string `json:"description"` 19 | EndDate any `json:"end_date"` 20 | GiverCoinReward any `json:"giver_coin_reward"` 21 | IconFormat *string `json:"icon_format"` 22 | IconHeight int `json:"icon_height"` 23 | IconURL string `json:"icon_url"` 24 | IconWidth int `json:"icon_width"` 25 | ID string `json:"id"` 26 | IsEnabled bool `json:"is_enabled"` 27 | IsNew bool `json:"is_new"` 28 | Name string `json:"name"` 29 | PennyDonate any `json:"penny_donate"` 30 | PennyPrice *int `json:"penny_price"` 31 | ResizedIcons []Image `json:"resized_icons,nilasempty"` 32 | ResizedStaticIcons []Image `json:"resized_static_icons,nilasempty"` 33 | StartDate any `json:"start_date"` 34 | StaticIconHeight int `json:"static_icon_height"` 35 | StaticIconURL string `json:"static_icon_url"` 36 | StaticIconWidth int `json:"static_icon_width"` 37 | StickyDurationSeconds any `json:"sticky_duration_seconds"` 38 | SubredditCoinReward int `json:"subreddit_coin_reward"` 39 | SubredditID any `json:"subreddit_id"` 40 | TiersByRequiredAwardings any `json:"tiers_by_required_awardings"` 41 | } 42 | 43 | type Flair struct { 44 | A string `json:"a,omitempty"` 45 | E string `json:"e"` 46 | T string `json:"t,omitempty"` 47 | U string `json:"u,omitempty"` 48 | } 49 | 50 | type GalleryItem struct { 51 | ID int `json:"id"` 52 | MediaID string `json:"media_id"` 53 | } 54 | 55 | type Gallery struct { 56 | Items []GalleryItem `json:"items,nilasempty"` 57 | } 58 | 59 | type Gilding struct { 60 | Gid2 int `json:"gid_2,omitempty"` 61 | Gid3 int `json:"gid_3,omitempty"` 62 | } 63 | 64 | type RedditVideo struct { 65 | BitrateKbps int `json:"bitrate_kbps"` 66 | DashURL string `json:"dash_url"` 67 | Duration int `json:"duration"` 68 | FallbackURL string `json:"fallback_url"` 69 | HasAudio bool `json:"has_audio"` 70 | Height int `json:"height"` 71 | HlsURL string `json:"hls_url"` 72 | IsGif bool `json:"is_gif"` 73 | ScrubberMediaURL string `json:"scrubber_media_url"` 74 | TranscodingStatus string `json:"transcoding_status"` 75 | Width int `json:"width"` 76 | } 77 | 78 | type Media struct { 79 | RedditVideo RedditVideo `json:"reddit_video"` 80 | } 81 | 82 | type MediaMetadata struct { 83 | E string `json:"e"` 84 | ID string `json:"id"` 85 | M string `json:"m"` 86 | O []struct { 87 | U string `json:"u"` 88 | X int `json:"x"` 89 | Y int `json:"y"` 90 | } `json:"o"` 91 | P []struct { 92 | U string `json:"u"` 93 | X int `json:"x"` 94 | Y int `json:"y"` 95 | } `json:"p"` 96 | S struct { 97 | U string `json:"u"` 98 | X int `json:"x"` 99 | Y int `json:"y"` 100 | } `json:"s"` 101 | Status string `json:"status"` 102 | } 103 | 104 | type PollData struct { 105 | IsPrediction bool `json:"is_prediction"` 106 | Options []struct { 107 | ID string `json:"id"` 108 | Text string `json:"text"` 109 | } `json:"options"` 110 | PredictionStatus any `json:"prediction_status"` 111 | ResolvedOptionID any `json:"resolved_option_id"` 112 | TotalStakeAmount any `json:"total_stake_amount"` 113 | TotalVoteCount int `json:"total_vote_count"` 114 | TournamentID any `json:"tournament_id"` 115 | UserSelection any `json:"user_selection"` 116 | UserWonAmount any `json:"user_won_amount"` 117 | VoteUpdatesRemained any `json:"vote_updates_remained"` 118 | VotingEndTimestamp int `json:"voting_end_timestamp"` 119 | } 120 | 121 | type MultiresImage struct { 122 | ID string `json:"id"` 123 | Resolutions []Image `json:"resolutions,nilasempty"` 124 | Source Image `json:"source"` 125 | Variants struct{} `json:"variants"` 126 | } 127 | 128 | type Preview struct { 129 | Enabled bool `json:"enabled"` 130 | Images []MultiresImage `json:"images,nilasempty"` 131 | } 132 | 133 | type Post struct { 134 | AllAwardings []Award `json:"all_awardings,nilasempty"` 135 | AllowLiveComments bool `json:"allow_live_comments"` 136 | ApprovedAtUtc any `json:"approved_at_utc"` 137 | ApprovedBy any `json:"approved_by"` 138 | Archived bool `json:"archived"` 139 | Author string `json:"author"` 140 | AuthorFlairBackgroundColor *string `json:"author_flair_background_color"` 141 | AuthorFlairCssClass *string `json:"author_flair_css_class"` 142 | AuthorFlairRichtext []Flair `json:"author_flair_richtext,nilasempty"` 143 | AuthorFlairTemplateID *string `json:"author_flair_template_id"` 144 | AuthorFlairText *string `json:"author_flair_text"` 145 | AuthorFlairTextColor *string `json:"author_flair_text_color"` 146 | AuthorFlairType string `json:"author_flair_type"` 147 | AuthorFullname string `json:"author_fullname"` 148 | AuthorIsBlocked bool `json:"author_is_blocked"` 149 | AuthorPatreonFlair bool `json:"author_patreon_flair"` 150 | AuthorPremium bool `json:"author_premium"` 151 | Awarders []any `json:"awarders,nilasempty"` 152 | BannedAtUtc any `json:"banned_at_utc"` 153 | BannedBy any `json:"banned_by"` 154 | CanGild bool `json:"can_gild"` 155 | CanModPost bool `json:"can_mod_post"` 156 | Category any `json:"category"` 157 | Clicked bool `json:"clicked"` 158 | ContentCategories []string `json:"content_categories,nilasempty"` 159 | ContestMode bool `json:"contest_mode"` 160 | Created int `json:"created"` 161 | CreatedUtc int `json:"created_utc"` 162 | DiscussionType any `json:"discussion_type"` 163 | Distinguished *string `json:"distinguished"` 164 | Domain *string `json:"domain"` 165 | Downs int `json:"downs"` 166 | Edited any `json:"edited"` 167 | GalleryData *Gallery `json:"gallery_data,omitempty"` 168 | Gilded int `json:"gilded"` 169 | Gildings Gilding `json:"gildings"` 170 | Hidden bool `json:"hidden"` 171 | HideScore bool `json:"hide_score"` 172 | ID string `json:"id"` 173 | IsCreatedFromAdsUi bool `json:"is_created_from_ads_ui"` 174 | IsCrosspostable bool `json:"is_crosspostable"` 175 | IsGallery bool `json:"is_gallery,omitempty"` 176 | IsMeta bool `json:"is_meta"` 177 | IsOriginalContent bool `json:"is_original_content"` 178 | IsRedditMediaDomain bool `json:"is_reddit_media_domain"` 179 | IsRobotIndexable bool `json:"is_robot_indexable"` 180 | IsSelf bool `json:"is_self"` 181 | IsVideo bool `json:"is_video"` 182 | Likes any `json:"likes"` 183 | LinkFlairBackgroundColor string `json:"link_flair_background_color"` 184 | LinkFlairCssClass *string `json:"link_flair_css_class"` 185 | LinkFlairRichtext []Flair `json:"link_flair_richtext,nilasempty"` 186 | LinkFlairTemplateID string `json:"link_flair_template_id,omitempty"` 187 | LinkFlairText *string `json:"link_flair_text"` 188 | LinkFlairTextColor string `json:"link_flair_text_color"` 189 | LinkFlairType string `json:"link_flair_type"` 190 | Locked bool `json:"locked"` 191 | Media *Media `json:"media"` 192 | MediaEmbed struct{} `json:"media_embed"` 193 | MediaMetadata *map[string]MediaMetadata `json:"media_metadata,omitempty"` 194 | MediaOnly bool `json:"media_only"` 195 | ModNote any `json:"mod_note"` 196 | ModReasonBy any `json:"mod_reason_by"` 197 | ModReasonTitle any `json:"mod_reason_title"` 198 | ModReports []any `json:"mod_reports"` 199 | Name string `json:"name"` 200 | NoFollow bool `json:"no_follow"` 201 | NumComments int `json:"num_comments"` 202 | NumCrossposts int `json:"num_crossposts"` 203 | NumReports any `json:"num_reports"` 204 | Over18 bool `json:"over_18"` 205 | ParentWhitelistStatus *string `json:"parent_whitelist_status"` 206 | Permalink string `json:"permalink"` 207 | Pinned bool `json:"pinned"` 208 | PollData *PollData `json:"poll_data,omitempty"` 209 | PostHint string `json:"post_hint,omitempty"` 210 | Preview *Preview `json:"preview,omitempty"` 211 | Pwls *int `json:"pwls"` 212 | Quarantine bool `json:"quarantine"` 213 | RemovalReason any `json:"removal_reason"` 214 | RemovedBy any `json:"removed_by"` 215 | RemovedByCategory any `json:"removed_by_category"` 216 | ReportReasons any `json:"report_reasons"` 217 | Saved bool `json:"saved"` 218 | Score int `json:"score"` 219 | SecureMedia *Media `json:"secure_media"` 220 | SecureMediaEmbed struct { 221 | } `json:"secure_media_embed"` 222 | Selftext string `json:"selftext"` 223 | SelftextHtml *string `json:"selftext_html"` 224 | SendReplies bool `json:"send_replies"` 225 | Spoiler bool `json:"spoiler"` 226 | Stickied bool `json:"stickied"` 227 | Subreddit string `json:"subreddit"` 228 | SubredditID string `json:"subreddit_id"` 229 | SubredditNamePrefixed string `json:"subreddit_name_prefixed"` 230 | SubredditSubscribers int `json:"subreddit_subscribers"` 231 | SubredditType string `json:"subreddit_type"` 232 | SuggestedSort *string `json:"suggested_sort"` 233 | Thumbnail string `json:"thumbnail"` 234 | ThumbnailHeight *int `json:"thumbnail_height"` 235 | ThumbnailWidth *int `json:"thumbnail_width"` 236 | Title string `json:"title"` 237 | TopAwardedType *string `json:"top_awarded_type"` 238 | TotalAwardsReceived int `json:"total_awards_received"` 239 | TreatmentTags []any `json:"treatment_tags"` 240 | Ups int `json:"ups"` 241 | UpvoteRatio float64 `json:"upvote_ratio"` 242 | URL *string `json:"url"` 243 | URLOverriddenByDest string `json:"url_overridden_by_dest,omitempty"` 244 | UserReports []any `json:"user_reports"` 245 | ViewCount any `json:"view_count"` 246 | Visited bool `json:"visited"` 247 | WhitelistStatus *string `json:"whitelist_status"` 248 | Wls *int `json:"wls"` 249 | } 250 | -------------------------------------------------------------------------------- /pkg/reddit_compat/reddit_test.go: -------------------------------------------------------------------------------- 1 | package reddit_compat_test 2 | 3 | import ( 4 | "backend/pkg/reddit_compat" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestAllHot(t *testing.T) { 12 | f, err := os.Open("../../resources/reddit/r/popular/hot.json") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | defer f.Close() 18 | dec := json.NewDecoder(f) 19 | dec.DisallowUnknownFields() 20 | 21 | var res reddit_compat.KindData[reddit_compat.Listing[reddit_compat.Post]] 22 | 23 | err = dec.Decode(&res) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | for _, v := range res.Data.Children { 29 | fmt.Printf("%s [r/%s]\n\t%s\n", v.Data.Title, v.Data.Subreddit, "https://reddit.com"+v.Data.Permalink) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/reddit_compat/subreddit.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type CommentContribSettings struct { 4 | AllowedMediaTypes []string `json:"allowed_media_types"` 5 | } 6 | 7 | type Subreddit struct { 8 | AcceptFollowers *bool `json:"accept_followers"` 9 | AccountsActive any `json:"accounts_active"` 10 | AccountsActiveIsFuzzed *bool `json:"accounts_active_is_fuzzed"` 11 | ActiveUserCount any `json:"active_user_count"` 12 | AdvertiserCategory *string `json:"advertiser_category"` 13 | AllOriginalContent *bool `json:"all_original_content"` 14 | AllowChatPostCreation bool `json:"allow_chat_post_creation"` 15 | AllowDiscovery *bool `json:"allow_discovery"` 16 | AllowGalleries *bool `json:"allow_galleries"` 17 | AllowImages *bool `json:"allow_images"` 18 | AllowPolls *bool `json:"allow_polls"` 19 | AllowPredictionContributors bool `json:"allow_prediction_contributors"` 20 | AllowPredictions bool `json:"allow_predictions"` 21 | AllowPredictionsTournament bool `json:"allow_predictions_tournament"` 22 | AllowTalks bool `json:"allow_talks"` 23 | AllowVideogifs bool `json:"allow_videogifs"` 24 | AllowVideos bool `json:"allow_videos"` 25 | AllowedMediaInComments []string `json:"allowed_media_in_comments"` 26 | BannerBackgroundColor *string `json:"banner_background_color"` 27 | BannerBackgroundImage string `json:"banner_background_image"` 28 | BannerImg *string `json:"banner_img"` 29 | BannerSize []int `json:"banner_size"` 30 | CanAssignLinkFlair bool `json:"can_assign_link_flair"` 31 | CanAssignUserFlair bool `json:"can_assign_user_flair"` 32 | CollapseDeletedComments *bool `json:"collapse_deleted_comments"` 33 | CommentContributionSettings CommentContribSettings `json:"comment_contribution_settings"` 34 | CommentScoreHideMins *int `json:"comment_score_hide_mins"` 35 | CommunityIcon string `json:"community_icon"` 36 | CommunityReviewed *bool `json:"community_reviewed"` 37 | ContentCategory string `json:"content_category,omitempty"` 38 | Created int `json:"created"` 39 | CreatedUtc int `json:"created_utc"` 40 | Description *string `json:"description"` 41 | DescriptionHtml *string `json:"description_html"` 42 | DisableContributorRequests *bool `json:"disable_contributor_requests"` 43 | DisplayName string `json:"display_name"` 44 | DisplayNamePrefixed string `json:"display_name_prefixed"` 45 | EmojisCustomSize []int `json:"emojis_custom_size"` 46 | EmojisEnabled bool `json:"emojis_enabled"` 47 | FreeFormReports *bool `json:"free_form_reports"` 48 | HasMenuWidget bool `json:"has_menu_widget"` 49 | HeaderImg *string `json:"header_img"` 50 | HeaderSize []int `json:"header_size"` 51 | HeaderTitle *string `json:"header_title"` 52 | HideAds *bool `json:"hide_ads"` 53 | IconImg *string `json:"icon_img"` 54 | IconSize []int `json:"icon_size"` 55 | ID string `json:"id"` 56 | IsChatPostFeatureEnabled bool `json:"is_chat_post_feature_enabled"` 57 | IsCrosspostableSubreddit bool `json:"is_crosspostable_subreddit"` 58 | IsEnrolledInNewModmail any `json:"is_enrolled_in_new_modmail"` 59 | KeyColor *string `json:"key_color"` 60 | Lang *string `json:"lang"` 61 | LinkFlairEnabled *bool `json:"link_flair_enabled"` 62 | LinkFlairPosition *string `json:"link_flair_position"` 63 | MobileBannerImage *string `json:"mobile_banner_image"` 64 | Name string `json:"name"` 65 | NotificationLevel any `json:"notification_level"` 66 | OriginalContentTagEnabled *bool `json:"original_content_tag_enabled"` 67 | Over18 *bool `json:"over18"` 68 | PredictionLeaderboardEntryType string `json:"prediction_leaderboard_entry_type"` 69 | PrimaryColor *string `json:"primary_color"` 70 | PublicDescription string `json:"public_description"` 71 | PublicDescriptionHtml *string `json:"public_description_html"` 72 | PublicTraffic *bool `json:"public_traffic"` 73 | Quarantine *bool `json:"quarantine"` 74 | RestrictCommenting *bool `json:"restrict_commenting"` 75 | RestrictPosting *bool `json:"restrict_posting"` 76 | ShouldArchivePosts *bool `json:"should_archive_posts"` 77 | ShouldShowMediaInCommentsSetting bool `json:"should_show_media_in_comments_setting"` 78 | ShowMedia *bool `json:"show_media"` 79 | ShowMediaPreview *bool `json:"show_media_preview"` 80 | SpoilersEnabled *bool `json:"spoilers_enabled"` 81 | SubmissionType *string `json:"submission_type"` 82 | SubmitLinkLabel *string `json:"submit_link_label"` 83 | SubmitText *string `json:"submit_text"` 84 | SubmitTextHtml *string `json:"submit_text_html"` 85 | SubmitTextLabel *string `json:"submit_text_label"` 86 | SubredditType string `json:"subreddit_type"` 87 | Subscribers *int `json:"subscribers"` 88 | SuggestedCommentSort *string `json:"suggested_comment_sort"` 89 | Title string `json:"title"` 90 | URL string `json:"url"` 91 | UserCanFlairInSr any `json:"user_can_flair_in_sr"` 92 | UserFlairBackgroundColor any `json:"user_flair_background_color"` 93 | UserFlairCssClass any `json:"user_flair_css_class"` 94 | UserFlairEnabledInSr *bool `json:"user_flair_enabled_in_sr"` 95 | UserFlairPosition *string `json:"user_flair_position"` 96 | UserFlairRichtext []any `json:"user_flair_richtext"` 97 | UserFlairTemplateID any `json:"user_flair_template_id"` 98 | UserFlairText any `json:"user_flair_text"` 99 | UserFlairTextColor any `json:"user_flair_text_color"` 100 | UserFlairType string `json:"user_flair_type"` 101 | UserHasFavorited any `json:"user_has_favorited"` 102 | UserIsBanned any `json:"user_is_banned"` 103 | UserIsContributor any `json:"user_is_contributor"` 104 | UserIsModerator any `json:"user_is_moderator"` 105 | UserIsMuted any `json:"user_is_muted"` 106 | UserIsSubscriber any `json:"user_is_subscriber"` 107 | UserSrFlairEnabled any `json:"user_sr_flair_enabled"` 108 | UserSrThemeEnabled *bool `json:"user_sr_theme_enabled"` 109 | VideostreamLinksCount int `json:"videostream_links_count,omitempty"` 110 | WhitelistStatus *string `json:"whitelist_status"` 111 | WikiEnabled *bool `json:"wiki_enabled"` 112 | Wls *int `json:"wls"` 113 | } 114 | -------------------------------------------------------------------------------- /pkg/reddit_compat/subreddit_details.go: -------------------------------------------------------------------------------- 1 | package reddit_compat 2 | 3 | type SubredditDetails struct { 4 | AcceptFollowers bool `json:"accept_followers"` 5 | AccountsActive int `json:"accounts_active"` 6 | AccountsActiveIsFuzzed bool `json:"accounts_active_is_fuzzed"` 7 | ActiveUserCount int `json:"active_user_count"` 8 | AdvertiserCategory string `json:"advertiser_category"` 9 | AllOriginalContent bool `json:"all_original_content"` 10 | AllowChatPostCreation bool `json:"allow_chat_post_creation"` 11 | AllowDiscovery bool `json:"allow_discovery"` 12 | AllowGalleries bool `json:"allow_galleries"` 13 | AllowImages bool `json:"allow_images"` 14 | AllowPolls bool `json:"allow_polls"` 15 | AllowPredictionContributors bool `json:"allow_prediction_contributors"` 16 | AllowPredictions bool `json:"allow_predictions"` 17 | AllowPredictionsTournament bool `json:"allow_predictions_tournament"` 18 | AllowTalks bool `json:"allow_talks"` 19 | AllowVideogifs bool `json:"allow_videogifs"` 20 | AllowVideos bool `json:"allow_videos"` 21 | AllowedMediaInComments []string `json:"allowed_media_in_comments"` 22 | BannerBackgroundColor string `json:"banner_background_color"` 23 | BannerBackgroundImage string `json:"banner_background_image"` 24 | BannerImg string `json:"banner_img"` 25 | BannerSize []int `json:"banner_size"` 26 | CanAssignLinkFlair bool `json:"can_assign_link_flair"` 27 | CanAssignUserFlair bool `json:"can_assign_user_flair"` 28 | CollapseDeletedComments bool `json:"collapse_deleted_comments"` 29 | CommentContributionSettings struct { 30 | AllowedMediaTypes []string `json:"allowed_media_types"` 31 | } `json:"comment_contribution_settings"` 32 | CommentScoreHideMins int `json:"comment_score_hide_mins"` 33 | CommunityIcon string `json:"community_icon"` 34 | CommunityReviewed bool `json:"community_reviewed"` 35 | Created int `json:"created"` 36 | CreatedUtc int `json:"created_utc"` 37 | Description string `json:"description"` 38 | DescriptionHtml string `json:"description_html"` 39 | DisableContributorRequests bool `json:"disable_contributor_requests"` 40 | DisplayName string `json:"display_name"` 41 | DisplayNamePrefixed string `json:"display_name_prefixed"` 42 | EmojisCustomSize any `json:"emojis_custom_size"` 43 | EmojisEnabled bool `json:"emojis_enabled"` 44 | FreeFormReports bool `json:"free_form_reports"` 45 | HasMenuWidget bool `json:"has_menu_widget"` 46 | HeaderImg string `json:"header_img"` 47 | HeaderSize []int `json:"header_size"` 48 | HeaderTitle string `json:"header_title"` 49 | HideAds bool `json:"hide_ads"` 50 | IconImg string `json:"icon_img"` 51 | IconSize []int `json:"icon_size"` 52 | ID string `json:"id"` 53 | IsChatPostFeatureEnabled bool `json:"is_chat_post_feature_enabled"` 54 | IsCrosspostableSubreddit bool `json:"is_crosspostable_subreddit"` 55 | IsEnrolledInNewModmail any `json:"is_enrolled_in_new_modmail"` 56 | KeyColor string `json:"key_color"` 57 | Lang string `json:"lang"` 58 | LinkFlairEnabled bool `json:"link_flair_enabled"` 59 | LinkFlairPosition string `json:"link_flair_position"` 60 | MobileBannerImage string `json:"mobile_banner_image"` 61 | Name string `json:"name"` 62 | NotificationLevel any `json:"notification_level"` 63 | OriginalContentTagEnabled bool `json:"original_content_tag_enabled"` 64 | Over18 bool `json:"over18"` 65 | PredictionLeaderboardEntryType string `json:"prediction_leaderboard_entry_type"` 66 | PrimaryColor string `json:"primary_color"` 67 | PublicDescription string `json:"public_description"` 68 | PublicDescriptionHtml string `json:"public_description_html"` 69 | PublicTraffic bool `json:"public_traffic"` 70 | Quarantine bool `json:"quarantine"` 71 | RestrictCommenting bool `json:"restrict_commenting"` 72 | RestrictPosting bool `json:"restrict_posting"` 73 | ShouldArchivePosts bool `json:"should_archive_posts"` 74 | ShouldShowMediaInCommentsSetting bool `json:"should_show_media_in_comments_setting"` 75 | ShowMedia bool `json:"show_media"` 76 | ShowMediaPreview bool `json:"show_media_preview"` 77 | SpoilersEnabled bool `json:"spoilers_enabled"` 78 | SubmissionType string `json:"submission_type"` 79 | SubmitLinkLabel string `json:"submit_link_label"` 80 | SubmitText string `json:"submit_text"` 81 | SubmitTextHtml string `json:"submit_text_html"` 82 | SubmitTextLabel string `json:"submit_text_label"` 83 | SubredditType string `json:"subreddit_type"` 84 | Subscribers int `json:"subscribers"` 85 | SuggestedCommentSort any `json:"suggested_comment_sort"` 86 | Title string `json:"title"` 87 | URL string `json:"url"` 88 | UserCanFlairInSr any `json:"user_can_flair_in_sr"` 89 | UserFlairBackgroundColor any `json:"user_flair_background_color"` 90 | UserFlairCssClass any `json:"user_flair_css_class"` 91 | UserFlairEnabledInSr bool `json:"user_flair_enabled_in_sr"` 92 | UserFlairPosition string `json:"user_flair_position"` 93 | UserFlairRichtext []any `json:"user_flair_richtext"` 94 | UserFlairTemplateID any `json:"user_flair_template_id"` 95 | UserFlairText any `json:"user_flair_text"` 96 | UserFlairTextColor any `json:"user_flair_text_color"` 97 | UserFlairType string `json:"user_flair_type"` 98 | UserHasFavorited any `json:"user_has_favorited"` 99 | UserIsBanned any `json:"user_is_banned"` 100 | UserIsContributor any `json:"user_is_contributor"` 101 | UserIsModerator any `json:"user_is_moderator"` 102 | UserIsMuted any `json:"user_is_muted"` 103 | UserIsSubscriber any `json:"user_is_subscriber"` 104 | UserSrFlairEnabled any `json:"user_sr_flair_enabled"` 105 | UserSrThemeEnabled bool `json:"user_sr_theme_enabled"` 106 | WhitelistStatus string `json:"whitelist_status"` 107 | WikiEnabled bool `json:"wiki_enabled"` 108 | Wls int `json:"wls"` 109 | } -------------------------------------------------------------------------------- /pkg/reddit_convert.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "backend/pkg/reddit_compat" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // toRedditPosts converts a slice of models.Post to a RedditPosts strucrandom_dudet 11 | func toRedditPosts(posts []models.Post, baseUrl string) (RedditPosts, error) { 12 | var listing RedditPosts 13 | listing.Kind = "Listing" 14 | for _, post := range posts { 15 | p := convertToRedditPost(&post, baseUrl) 16 | listing.Data.Children = append(listing.Data.Children, p) 17 | } 18 | 19 | if len(listing.Data.Children) > 0 { 20 | last := "t3_" + listing.Data.Children[len(listing.Data.Children)-1].Data.ID 21 | listing.Data.After = &last 22 | } 23 | 24 | return listing, nil 25 | } 26 | 27 | func convertToRedditPost(post *models.Post, baseUrl string) reddit_compat.KindData[reddit_compat.Post] { 28 | postHint := "text" 29 | if post.Link != nil { 30 | postHint = "link" 31 | } 32 | p := reddit_compat.KindData[reddit_compat.Post]{ 33 | Kind: "t3", 34 | Data: reddit_compat.Post{ 35 | ID: fmt.Sprintf("%d", post.ID), 36 | Name: fmt.Sprintf("t3_%d", post.ID), 37 | Title: post.Title, 38 | Selftext: post.Body, 39 | SelftextHtml: &post.Body, 40 | Subreddit: post.RingName, 41 | SubredditNamePrefixed: prefixSubreddit(post.RingName), 42 | Author: post.AuthorUsername, 43 | Permalink: fmt.Sprintf("/r/%s/comments/%d/%s", post.RingName, post.ID, seoTitle(post.Title)), 44 | Ups: post.Ups, 45 | Downs: post.Downs, 46 | Score: post.Score, 47 | NumComments: post.CommentsCount, 48 | URL: post.Link, 49 | Domain: post.Domain, 50 | Created: int(post.CreatedAt.Unix()), 51 | CreatedUtc: int(post.CreatedAt.UTC().Unix()), 52 | Over18: post.Nsfw, 53 | PostHint: postHint, 54 | }, 55 | } 56 | 57 | if p.Data.Domain == nil { 58 | myUrl := baseUrl + p.Data.Permalink 59 | selfRingName := "self." + post.RingName 60 | p.Data.Domain = &selfRingName 61 | p.Data.Thumbnail = "self" 62 | p.Data.URL = &myUrl 63 | p.Data.AuthorFlairType = "text" 64 | p.Data.LinkFlairType = "text" 65 | } 66 | 67 | p.Data = *parseNilAsEmpty(&p.Data) 68 | return p 69 | } 70 | 71 | func prefixSubreddit(name string) string { 72 | return "r/" + name 73 | } 74 | 75 | func toRedditPost(post *models.Post, baseUrl string) (reddit_compat.KindData[reddit_compat.Post], error) { 76 | return convertToRedditPost(post, baseUrl), nil 77 | } 78 | 79 | func toRedditSubreddits(rings []models.Ring) (RedditSubreddits, error) { 80 | var listing RedditSubreddits 81 | listing.Kind = "Listing" 82 | for _, ring := range rings { 83 | red := "#FF0000" 84 | iconImg := "https://a.thumbs.redditmedia.com/E0Bkwgwe5TkVLflBA7WMe9fMSC7DV2UOeff-UpNJeb0.png" 85 | subscribers := int(ring.Subscribers) 86 | s := reddit_compat.KindData[reddit_compat.Subreddit]{ 87 | Kind: "t5", 88 | Data: reddit_compat.Subreddit{ 89 | ID: ring.Name, 90 | Title: ring.Title, 91 | Name: ring.Name, 92 | DisplayName: ring.Name, 93 | Description: &ring.Description, 94 | Over18: &ring.Nsfw, 95 | URL: "/r/" + ring.Name, 96 | DisplayNamePrefixed: "r/" + ring.Name, 97 | BannerBackgroundColor: &red, 98 | IconImg: &iconImg, 99 | Subscribers: &subscribers, 100 | }, 101 | } 102 | listing.Data.Children = append(listing.Data.Children, s) 103 | } 104 | 105 | return listing, nil 106 | } 107 | 108 | func toRingAbout(ring *models.Ring) RedditAbout { 109 | subscribers := int(ring.Subscribers) 110 | return RedditAbout{ 111 | Kind: "t5", 112 | Data: reddit_compat.SubredditDetails{ 113 | ID: ring.Name, 114 | Title: ring.Title, 115 | Name: ring.Name, 116 | DisplayName: ring.Name, 117 | Description: ring.Description, 118 | Over18: ring.Nsfw, 119 | URL: "/r/" + ring.Name, 120 | DisplayNamePrefixed: "r/" + ring.Name, 121 | Subscribers: subscribers, 122 | DescriptionHtml: ring.Description, 123 | Created: int(ring.CreatedAt.Unix()), 124 | CreatedUtc: int(ring.CreatedAt.UTC().Unix()), 125 | PrimaryColor: ring.PrimaryColor, 126 | ActiveUserCount: 19, 127 | }, 128 | } 129 | } 130 | 131 | func toRedditComments(post *models.Post, comments []models.Comment, baseUrl string) ([]any, error) { 132 | if post == nil { 133 | return nil, fmt.Errorf("post is nil") 134 | } 135 | redditPost, err := toRedditPost(post, baseUrl) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | listing := toRedditCommentsInner(post, comments, 0) 141 | 142 | if listing.Data.Children == nil { 143 | listing.Data.Children = []reddit_compat.KindData[reddit_compat.Comment]{} 144 | } 145 | if len(listing.Data.Children) > 0 { 146 | listing.Data.After = &listing.Data.Children[len(listing.Data.Children)-1].Data.ID 147 | } 148 | 149 | return []any{ 150 | wrapListing( 151 | []reddit_compat.KindData[reddit_compat.Post]{redditPost}, 152 | ), 153 | listing, 154 | }, nil 155 | } 156 | 157 | func toRedditCommentsInner(post *models.Post, comments []models.Comment, depth int) RedditComments { 158 | var listing RedditComments 159 | listing.Kind = "Listing" 160 | for _, comment := range comments { 161 | c := reddit_compat.KindData[reddit_compat.Comment]{ 162 | Kind: "t1", 163 | Data: reddit_compat.Comment{ 164 | ID: fmt.Sprintf("%d", comment.ID), 165 | Body: comment.Body, 166 | BodyHtml: comment.Body, 167 | Author: comment.AuthorUsername, 168 | Subreddit: post.RingName, 169 | SubredditNamePrefixed: "r/" + post.RingName, 170 | Permalink: getCommentPermalink(post.RingName, comment.PostId, comment.ID), 171 | LinkID: fmt.Sprintf("t3_%d", post.ID), 172 | Score: int(comment.Ups - comment.Downs), 173 | Created: int(comment.CreatedAt.Unix()), 174 | CreatedUtc: int(comment.CreatedAt.UTC().Unix()), 175 | Replies: toRedditCommentsInner(post, comment.Replies, depth+1), 176 | Depth: depth, 177 | }, 178 | } 179 | c.Data = *parseNilAsEmpty(&c.Data) 180 | listing.Data.Children = append(listing.Data.Children, c) 181 | } 182 | if listing.Data.Children == nil { 183 | listing.Data.Children = []reddit_compat.KindData[reddit_compat.Comment]{} 184 | } 185 | return listing 186 | } 187 | 188 | func getCommentPermalink(name string, postId uint, commentId uint) string { 189 | return fmt.Sprintf("/r/%s/comments/%d/%d", name, postId, commentId) 190 | } 191 | 192 | func wrapListing[T any](posts []reddit_compat.KindData[T]) reddit_compat.KindData[reddit_compat.Listing[T]] { 193 | return reddit_compat.KindData[reddit_compat.Listing[T]]{ 194 | Kind: "Listing", 195 | Data: reddit_compat.Listing[T]{ 196 | Children: posts, 197 | }, 198 | } 199 | } 200 | 201 | // seoTitle converts the Title into a URL friendly string for the permalink 202 | func seoTitle(title string) string { 203 | return strings.Replace(strings.ToLower(title), " ", "-", -1) 204 | } 205 | -------------------------------------------------------------------------------- /pkg/reddit_short_types.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/reddit_compat" 4 | 5 | type RedditPosts reddit_compat.KindData[reddit_compat.Listing[reddit_compat.Post]] 6 | type RedditSubreddits reddit_compat.KindData[reddit_compat.Listing[reddit_compat.Subreddit]] 7 | type RedditAbout reddit_compat.KindData[reddit_compat.SubredditDetails] 8 | type RedditComments reddit_compat.KindData[reddit_compat.Listing[reddit_compat.Comment]] 9 | -------------------------------------------------------------------------------- /pkg/repo_comments.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func (s *Server) repoComments(postId uint, parentId *uint) ([]models.Comment, error) { 10 | var comments []models.Comment 11 | 12 | tx := s.db. 13 | Limit(200). 14 | Preload("Author").Order("score desc") 15 | var err error 16 | if parentId == nil { 17 | // Postgres doesn't like to compare NULLs with =, so we have to do this. 18 | err = tx. 19 | Find(&comments, "post_id = ? AND parent_id IS NULL", postId).Error 20 | } else { 21 | err = tx. 22 | Find(&comments, "post_id = ? AND parent_id = ?", postId, parentId).Error 23 | } 24 | 25 | if err != nil { 26 | return nil, err 27 | } 28 | return comments, nil 29 | } 30 | 31 | func (s *Server) repoComment(commentId uint) (models.Comment, error) { 32 | var comment models.Comment 33 | tx := s.db.Preload("Author").First(&comment, "id = ?", commentId) 34 | return comment, tx.Error 35 | } 36 | 37 | func (s *Server) repoGetCommentTree(postId uint, commentId uint) ([]models.Comment, error) { 38 | var comments []models.Comment 39 | err := s.db.Raw(` 40 | WITH RECURSIVE comment_tree AS ( 41 | SELECT 42 | c.* 43 | FROM 44 | comments c 45 | WHERE 46 | c.id = ? AND post_id = ? 47 | UNION ALL 48 | SELECT 49 | cr.* 50 | FROM 51 | comments cr 52 | JOIN comment_tree ct ON cr.parent_id = ct.id 53 | ) 54 | SELECT 55 | ct.*, 56 | u.username AS author_username 57 | FROM 58 | comment_tree ct 59 | JOIN users u ON ct.author_username = u.username 60 | ORDER BY ct.depth`, 61 | commentId, 62 | postId, 63 | ). 64 | Scan(&comments). 65 | Error 66 | 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return comments, nil 72 | } 73 | 74 | func (s *Server) repoGetTopComments(postID uint) ([]models.Comment, error) { 75 | var comments []models.Comment 76 | 77 | maxLevel := 5 78 | topComments := 10 79 | err := s.db.Model(&models.Comment{}).Preload("Author").Raw(` 80 | WITH RECURSIVE comment_hierarchy AS ( 81 | SELECT 82 | c.*, 83 | 0 AS level 84 | FROM 85 | comments c 86 | WHERE 87 | c.post_id = ? 88 | AND c.parent_id IS NULL 89 | UNION ALL 90 | SELECT 91 | cr.*, 92 | ch.level + 1 AS level 93 | FROM 94 | comments cr 95 | JOIN comment_hierarchy ch ON cr.parent_id = ch.id 96 | WHERE 97 | ch.level < ? 98 | ) 99 | SELECT 100 | ch.*, 101 | u.username AS author_username 102 | FROM 103 | comment_hierarchy ch 104 | JOIN users u ON ch.author_username = u.username 105 | ORDER BY 106 | ch.score DESC 107 | LIMIT ? 108 | `, postID, maxLevel, topComments).Scan(&comments).Error 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | // Fill the users 114 | usernamesMap := map[string]bool{} 115 | for _, comment := range comments { 116 | usernamesMap[comment.AuthorUsername] = true 117 | } 118 | 119 | var usernames []string 120 | for username := range usernamesMap { 121 | usernames = append(usernames, username) 122 | } 123 | 124 | usersMap := map[string]models.User{} 125 | users, err := s.repoUsers(usernames) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | for _, user := range users { 131 | usersMap[user.Username] = user 132 | } 133 | 134 | for k, comment := range comments { 135 | // Check if user exists in map 136 | v, ok := usersMap[comment.AuthorUsername] 137 | if !ok { 138 | return nil, fmt.Errorf("user %s not found", comment.AuthorUsername) 139 | } 140 | comments[k].Author = v 141 | } 142 | 143 | return comments, nil 144 | } 145 | 146 | func (s *Server) repoDeleteComment(commentId uint) error { 147 | tx := s.db.Model(&models.Comment{}). 148 | Where("id = ?", commentId). 149 | Update("deleted_at", time.Now()) 150 | return tx.Error 151 | } 152 | 153 | func (s *Server) repoCountComments() (int64, error) { 154 | var count int64 155 | err := s.db.Model(&models.Comment{}).Count(&count).Error 156 | return count, err 157 | } 158 | -------------------------------------------------------------------------------- /pkg/repo_comments_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestRepoCommentsTree(t *testing.T) { 10 | s := testGetNewServer(t) 11 | comments, err := s.repoGetTopComments(1) 12 | if err != nil { 13 | t.Fatalf("unable to get comments: %v", err) 14 | } 15 | 16 | for _, c := range comments { 17 | fmt.Printf("%s: %s\n", c.AuthorUsername, c.Body) 18 | } 19 | } 20 | func TestRetrieveComments(t *testing.T) { 21 | s := testGetNewServer(t) 22 | comments, done := s.retrieveComments(nil, 1, nil, map[uint]models.CommentAction{}) 23 | if done { 24 | t.Fatalf("done should be false") 25 | } 26 | 27 | for _, c := range comments { 28 | fmt.Printf("%s: %s\n", c.AuthorUsername, c.Body) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/repo_posts.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "backend/pkg/request" 6 | "errors" 7 | "fmt" 8 | "gorm.io/gorm" 9 | "net/url" 10 | ) 11 | 12 | func (s *Server) repoCreatePost(req request.PostCreate, username string) (*models.Post, error) { 13 | // Check if ring exists 14 | ringExists, err := s.repoRingExists(req.Ring) 15 | if err != nil { 16 | return nil, fmt.Errorf("unable to check if ring exists: %v", err) 17 | } 18 | 19 | if !ringExists { 20 | return nil, fmt.Errorf(ErrRingDoesNotExist) 21 | } 22 | 23 | // Validate Post Request 24 | err = req.Validate() 25 | if err != nil { 26 | s.logger.Warnf("invalid post create request: %v", err) 27 | return nil, fmt.Errorf(ErrInvalidPostRequest) 28 | } 29 | 30 | // Create post 31 | post := models.Post{ 32 | Title: req.Title, 33 | Nsfw: req.Nsfw, 34 | RingName: req.Ring, 35 | AuthorUsername: username, 36 | } 37 | 38 | if req.Link != nil { 39 | post.Link = req.Link 40 | 41 | // Determine domain 42 | domain, err := getDomain(*req.Link) 43 | if err != nil { 44 | return nil, fmt.Errorf("unable to get domain: %v", err) 45 | } 46 | post.Domain = &domain 47 | } 48 | 49 | if req.Body != nil { 50 | post.Body = *req.Body 51 | } 52 | 53 | err = s.db.Create(&post).Error 54 | if err != nil { 55 | return nil, fmt.Errorf("unable to create post: %v", err) 56 | } 57 | return &post, nil 58 | } 59 | 60 | func getDomain(s string) (string, error) { 61 | u, err := url.Parse(s) 62 | if err != nil { 63 | return "", fmt.Errorf("invalid link") 64 | } 65 | return u.Hostname(), nil 66 | } 67 | 68 | func (s *Server) repoVoteAction(action models.VoteAction, username string, id int64) error { 69 | // Check if post exists 70 | post, err := s.repoPost(uint(id)) 71 | if err != nil { 72 | s.logger.Warnf("unable to get post: %v", err) 73 | return fmt.Errorf(ErrUnableToGetPost) 74 | } 75 | 76 | // Check if user has already voted 77 | vote, err := s.repoGetVote(username, uint(id)) 78 | if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 79 | s.logger.Warnf("unable to get vote: %v", err) 80 | return fmt.Errorf(ErrUnableToGetVote) 81 | } 82 | 83 | if vote != nil { 84 | if vote.Action == action { 85 | // User has already voted with this action 86 | return fmt.Errorf(ErrUnableToVoteUserAlreadyVoted) 87 | } 88 | 89 | vote.Action = action 90 | 91 | // Run in a transaction 92 | tx := s.db.Begin() 93 | err = tx.Save(&vote).Error 94 | if err != nil { 95 | s.logger.Warnf("unable to save vote: %v", err) 96 | tx.Rollback() 97 | return fmt.Errorf(ErrUnableToSaveVote) 98 | } 99 | 100 | increase := 2 101 | if action == models.ActionDownvote { 102 | increase = -2 103 | } 104 | // Vote saved, update post, add 2 to score (1 for upvote, 1 for removing downvote) 105 | err = s.repoIncreasePostScore(tx, post.ID, increase) 106 | if err != nil { 107 | s.logger.Warnf("unable to increase post score by %d: %v", increase, err) 108 | tx.Rollback() 109 | return fmt.Errorf(ErrUnableToIncreasePostScore) 110 | } 111 | tx.Commit() 112 | } else { 113 | // User has not voted yet 114 | vote := models.PostAction{ 115 | Username: username, 116 | PostId: uint(id), 117 | Action: action, 118 | } 119 | 120 | // Run in a transaction 121 | tx := s.db.Begin() 122 | err = tx.Create(&vote).Error 123 | if err != nil { 124 | s.logger.Warnf("unable to create vote: %v", err) 125 | tx.Rollback() 126 | return fmt.Errorf(ErrUnableToCreateVote) 127 | } 128 | // Vote saved, update post, add +-1 to score 129 | increase := 1 130 | if action == models.ActionDownvote { 131 | increase = -1 132 | } 133 | err = s.repoIncreasePostScore(tx, post.ID, increase) 134 | if err != nil { 135 | s.logger.Warnf("unable to increase post score by %d: %v", increase, err) 136 | tx.Rollback() 137 | return fmt.Errorf(ErrUnableToIncreasePostScore) 138 | } 139 | tx.Commit() 140 | } 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /pkg/repo_posts_votes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/models" 4 | 5 | func (s *Server) repoGetVote(username string, postId uint) (*models.PostAction, error) { 6 | var postAction models.PostAction 7 | tx := s.db.First(&postAction, "username = ? AND post_id = ?", username, postId) 8 | if tx.Error != nil { 9 | return nil, tx.Error 10 | } 11 | 12 | return &postAction, nil 13 | } 14 | -------------------------------------------------------------------------------- /pkg/repo_ring.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/models" 4 | 5 | func (s *Server) repoRingExists(ringName string) (bool, error) { 6 | var ring models.Ring 7 | err := s.db.First(&ring, "name = ?", ringName).Error 8 | if err != nil { 9 | return false, err 10 | } 11 | return true, nil 12 | } 13 | -------------------------------------------------------------------------------- /pkg/repo_rings.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "fmt" 6 | ) 7 | 8 | func (s *Server) repoGetTotalRings() (int64, error) { 9 | var total int64 10 | tx := s.db.Model(&models.Ring{}).Where("deleted_at IS NULL").Count(&total) 11 | if tx.Error != nil { 12 | s.logger.Errorf("Unable to get total rings: %v", tx.Error) 13 | return 0, fmt.Errorf("unable to get total rings") 14 | } 15 | 16 | return total, nil 17 | } 18 | 19 | func (s *Server) repoGetRings(offset int, limit uint64) ([]models.Ring, error) { 20 | var rings []models.Ring 21 | 22 | tx := s.db 23 | if offset != 0 { 24 | tx = tx.Offset(offset) 25 | } 26 | 27 | tx.Preload("Owner"). 28 | Limit(int(limit)). 29 | Where("deleted_at IS NULL"). 30 | Order("subscribers DESC"). 31 | Find(&rings) 32 | if tx.Error != nil { 33 | s.logger.Errorf("Unable to get rings: %v", tx.Error) 34 | return nil, fmt.Errorf("unable to get rings") 35 | } 36 | 37 | return rings, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/repo_rings_search.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func (s *Server) repoRingsSearch(q string, nsfw bool, after string, limit uint64) ([]models.Ring, error) { 10 | var rings []models.Ring 11 | q = strings.Replace(q, "%", "\\%", -1) 12 | q = strings.ToLower(q) 13 | 14 | tx := s.db 15 | if after != "" { 16 | afterV, err := strconv.ParseUint(after, 10, 64) 17 | if err != nil { 18 | return nil, err 19 | } 20 | tx = tx.Where("subscribers < ?", afterV) 21 | } 22 | tx. 23 | Preload("Owner"). 24 | Limit(int(limit)). 25 | Order("subscribers DESC"). 26 | Find(&rings, "name LIKE ? AND nsfw IN (?, false)", q+"%", nsfw) 27 | if tx.Error != nil { 28 | return nil, tx.Error 29 | } 30 | 31 | return rings, nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/repo_rings_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestRepoRings(t *testing.T) { 9 | s := testGetNewServer(t) 10 | rings, err := s.repoGetRings(5, 100) 11 | if err != nil { 12 | t.Fatalf("unable to get rings: %v", err) 13 | } 14 | 15 | for _, r := range rings { 16 | fmt.Printf("%s\n", r.Name) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/repo_users.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "backend/pkg/models" 4 | 5 | // repoUsers returns a list of users matching the given usernames. 6 | func (s *Server) repoUsers(usernames []string) ([]models.User, error) { 7 | var users []models.User 8 | err := s.db. 9 | Where("username IN (?)", usernames). 10 | Where("deleted_at IS NULL"). 11 | Find(&users).Error 12 | 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return users, nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/repository.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "gorm.io/gorm/clause" 6 | ) 7 | 8 | func (s *Server) repoRingPosts(ringName string) ([]models.Post, error) { 9 | var ring models.Ring 10 | err := s.db. 11 | Limit(100). 12 | Preload(clause.Associations).First(&ring, "name = ?", ringName).Error 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return ring.Posts, nil 18 | } 19 | 20 | func (s *Server) repoPost(postId uint) (*models.Post, error) { 21 | var post models.Post 22 | err := s.db.Preload(clause.Associations).First(&post, "id = ?", postId).Error 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &post, nil 27 | } 28 | 29 | func (s *Server) repoRingAbout(ringName string) (*models.Ring, error) { 30 | var ring models.Ring 31 | err := s.db.First(&ring, "name = ?", ringName).Error 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &ring, nil 36 | } 37 | 38 | func (s *Server) repoGetUserByAuthSubject(subject string) (*models.User, error) { 39 | var user models.User 40 | err := s.db.First(&user, "auth_subject = ?", subject).Error 41 | if err != nil { 42 | return nil, err 43 | } 44 | return &user, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/request/comment.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type Comment struct { 4 | Content string `json:"content"` 5 | ParentId *uint `json:"parentId"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/request/create_ring.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | ) 7 | 8 | type CreateRingRequest struct { 9 | Title string `json:"title"` 10 | Description string `json:"description"` 11 | Color string `json:"color"` 12 | } 13 | 14 | var colorRegexp = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) 15 | 16 | func (r CreateRingRequest) Validate() error { 17 | if len(r.Title) > 100 { 18 | return errors.New("title too long") 19 | } 20 | 21 | if len(r.Description) > 1000 { 22 | return errors.New("description too long") 23 | } 24 | 25 | if !colorRegexp.MatchString(r.Color) { 26 | return errors.New("invalid color") 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/request/post_create.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | type PostCreate struct { 10 | Title string `json:"title"` 11 | Body *string `json:"body,omitempty"` 12 | Link *string `json:"link,omitempty"` 13 | Nsfw bool `json:"nsfw"` 14 | Ring string `json:"ring"` 15 | } 16 | 17 | func (c PostCreate) Validate() error { 18 | // Titles can be maximum 300 characters 19 | if len(c.Title) > 300 { 20 | return fmt.Errorf("title cannot be longer than 60 characters") 21 | } 22 | 23 | if len(strings.TrimSpace(c.Title)) == 0 { 24 | return fmt.Errorf("title cannot be empty") 25 | } 26 | 27 | // Body can be maximum 1000 characters 28 | if c.Body != nil && len(*c.Body) > 1000 { 29 | return fmt.Errorf("body cannot be longer than 1000 characters") 30 | } 31 | 32 | // Link can be maximum 300 characters 33 | if c.Link != nil && len(*c.Link) > 300 { 34 | return fmt.Errorf("link cannot be longer than 300 characters") 35 | } 36 | 37 | // Parse link 38 | if c.Link != nil { 39 | u, err := url.Parse(*c.Link) 40 | if err != nil { 41 | return fmt.Errorf("invalid link") 42 | } 43 | 44 | if u.Scheme != "http" && u.Scheme != "https" { 45 | return fmt.Errorf("invalid link scheme") 46 | } 47 | } 48 | 49 | if c.Link != nil && c.Body != nil { 50 | return fmt.Errorf("cannot have both link and body") 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/response/paginated.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type Paginated[T any] struct { 4 | Items []T `json:"items"` 5 | 6 | // The total number of items. 7 | Total int64 `json:"total"` 8 | 9 | After string `json:"after"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/response/post.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "time" 6 | ) 7 | 8 | type Post struct { 9 | // A post is a message in a ring. 10 | ID int `json:"id" gorm:"primaryKey"` 11 | CreatedAt time.Time `json:"createdAt"` 12 | 13 | // Ring attributes 14 | RingName string `json:"ringName"` 15 | RingColor string `json:"ringColor"` 16 | 17 | AuthorUsername string `json:"authorUsername" gorm:"index"` 18 | Author *models.User `json:"author"` 19 | Title string `json:"title"` 20 | Body string `json:"body,omitempty"` 21 | Link *string `json:"link"` 22 | Domain *string `json:"domain" gorm:"index"` 23 | Score int `json:"score"` 24 | CommentsCount int `json:"commentsCount"` 25 | Ups int `json:"ups"` 26 | Downs int `json:"downs"` 27 | Nsfw bool `json:"nsfw"` 28 | VotedUp bool `json:"votedUp"` 29 | VotedDown bool `json:"votedDown"` 30 | } 31 | -------------------------------------------------------------------------------- /pkg/ring_about.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | func (s *Server) getRcRingAbout(context *gin.Context) { 9 | about, err := s.repoRingAbout(context.Param("ring")) 10 | if err != nil { 11 | context.AbortWithStatus(http.StatusInternalServerError) 12 | return 13 | } 14 | 15 | redditAbout := toRingAbout(about) 16 | context.JSON(200, redditAbout) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/route_index.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | func (s *Server) indexRoute(c *gin.Context) { 9 | c.String(http.StatusOK, "Welcome to the rings-social backend.\n\n"+ 10 | "This backend is Reddit API compatible.\n\n"+ 11 | "Learn more at https://github.com/rings-social/backend.\n"+ 12 | "Connect your app (Sync, Apollo, etc.) to this endpoint and enjoy the rings-social experience.\n"+ 13 | "Alternatively, visit https://rings.social to use the web client.") 14 | } 15 | -------------------------------------------------------------------------------- /pkg/route_post.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "github.com/gin-gonic/gin" 6 | "net/http" 7 | ) 8 | 9 | func (s *Server) innerRouteVotePost(c *gin.Context, action models.VoteAction) { 10 | postId, done := parsePostId(c) 11 | if done { 12 | return 13 | } 14 | 15 | username := c.GetString("username") 16 | err := s.repoVoteAction(action, username, postId) 17 | if err != nil { 18 | if err.Error() == ErrUnableToVoteUserAlreadyVoted { 19 | c.JSON(http.StatusOK, gin.H{"error": "user already voted"}) 20 | return 21 | } 22 | s.logger.Errorf("unable to vote post: %v", err) 23 | c.JSON(500, gin.H{"error": "unable to vote post"}) 24 | return 25 | } 26 | 27 | c.JSON(http.StatusAccepted, gin.H{"message": "voted"}) 28 | } 29 | 30 | func (s *Server) routeUpvotePost(c *gin.Context) { 31 | s.innerRouteVotePost(c, models.ActionUpvote) 32 | } 33 | 34 | func (s *Server) routeDownvotePost(c *gin.Context) { 35 | s.innerRouteVotePost(c, models.ActionDownvote) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/route_rings.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "backend/pkg/request" 6 | "backend/pkg/response" 7 | "fmt" 8 | "github.com/gin-gonic/gin" 9 | "gorm.io/gorm" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | func (s *Server) routeGetRing(context *gin.Context) { 16 | ringName := context.Param("ring") 17 | if ringName == "" { 18 | context.AbortWithStatusJSON(400, gin.H{ 19 | "error": "Ring name is required", 20 | }) 21 | return 22 | } 23 | 24 | var ring models.Ring 25 | tx := s.db.First(&ring, "name = ?", ringName) 26 | if tx.Error != nil { 27 | // If tx reports not found: 28 | if tx.Error == gorm.ErrRecordNotFound { 29 | context.AbortWithStatusJSON(404, gin.H{ 30 | "error": "Ring not found", 31 | }) 32 | return 33 | } 34 | // Otherwise, it's an internal error: 35 | s.logger.Errorf("Unable to get ring %s: %v", ringName, tx.Error) 36 | context.AbortWithStatusJSON(500, gin.H{ 37 | "error": "Unable to get ring", 38 | }) 39 | return 40 | } 41 | 42 | context.JSON(200, ring) 43 | } 44 | 45 | func (s *Server) routeGetRings(c *gin.Context) { 46 | // Get paginated request 47 | pagination, done := s.getPagination(c) 48 | if done { 49 | return 50 | } 51 | 52 | // Check for query parameter 53 | nsfw := false 54 | nsfwParam := c.Query("nsfw") 55 | if strings.ToLower(nsfwParam) == "true" { 56 | nsfw = true 57 | } 58 | 59 | total, err := s.repoGetTotalRings() 60 | if err != nil { 61 | s.logger.Errorf("Unable to get total rings: %v", err) 62 | internalServerError(c) 63 | return 64 | } 65 | 66 | q := c.Query("q") 67 | if q != "" { 68 | rings, err := s.repoRingsSearch(q, nsfw, pagination.After, pagination.Limit) 69 | if err != nil { 70 | s.logger.Errorf("Unable to search rings: %v", err) 71 | internalServerError(c) 72 | return 73 | } 74 | after := "" 75 | if len(rings) > 0 { 76 | after = fmt.Sprintf("%d", rings[len(rings)-1].Subscribers) 77 | } 78 | 79 | returnPaginated(c, after, rings, -1) 80 | return 81 | } 82 | 83 | offset := 0 84 | if pagination.After != "" { 85 | var err error 86 | offset, err = strconv.Atoi(pagination.After) 87 | if err != nil { 88 | s.logger.Errorf("Unable to parse after %s: %v", pagination.After, err) 89 | internalServerError(c) 90 | return 91 | } 92 | } 93 | rings, err := s.repoGetRings(offset, pagination.Limit) 94 | if err != nil { 95 | s.logger.Errorf("Unable to get rings: %v", err) 96 | internalServerError(c) 97 | return 98 | } 99 | 100 | after := "" 101 | afterV := offset + int(pagination.Limit) 102 | 103 | if len(rings) > 0 { 104 | after = fmt.Sprintf("%d", afterV) 105 | } 106 | 107 | // Paginated result 108 | returnPaginated(c, after, rings, total) 109 | } 110 | 111 | func returnPaginated[T any](c *gin.Context, after string, items []T, total int64) { 112 | paginatedResult := response.Paginated[T]{ 113 | After: after, 114 | Items: items, 115 | Total: total, 116 | } 117 | c.JSON(200, paginatedResult) 118 | } 119 | 120 | func (s *Server) routeCreateRing(c *gin.Context) { 121 | username, exists := c.Get("username") 122 | if !exists { 123 | return 124 | } 125 | 126 | ringName := c.Param("ring") 127 | isValidRingName := validateRingName(ringName) 128 | if !isValidRingName { 129 | c.AbortWithStatusJSON(http.StatusBadRequest, 130 | gin.H{"error": "Invalid ring name"}, 131 | ) 132 | return 133 | } 134 | 135 | var ringRequest request.CreateRingRequest 136 | err := c.BindJSON(&ringRequest) 137 | if err != nil { 138 | c.AbortWithStatusJSON(400, gin.H{ 139 | "error": "Invalid request body", 140 | }) 141 | return 142 | } 143 | 144 | err = ringRequest.Validate() 145 | 146 | ring := models.Ring{ 147 | Name: ringName, 148 | Title: ringRequest.Title, 149 | Description: ringRequest.Description, 150 | OwnerUsername: username.(string), 151 | PrimaryColor: ringRequest.Color, 152 | } 153 | 154 | tx := s.db.Create(&ring) 155 | if tx.Error != nil { 156 | s.logger.Errorf("Unable to create ring: %v", tx.Error) 157 | c.AbortWithStatusJSON(500, gin.H{ 158 | "error": "Unable to create ring", 159 | }) 160 | return 161 | } 162 | 163 | c.JSON(200, ring) 164 | } 165 | -------------------------------------------------------------------------------- /pkg/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func (s *Server) initRoutes() { 9 | s.g.Use(gin.Recovery()) 10 | s.g.Use(gin.Logger()) 11 | s.g.Use(cors.New(cors.Config{ 12 | AllowAllOrigins: true, 13 | AllowCredentials: true, 14 | AllowHeaders: []string{ 15 | "Origin", "Content-Length", "Content-Type", "Authorization", 16 | }, 17 | AllowMethods: []string{ 18 | "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", 19 | }, 20 | })) 21 | s.g.GET("/healthz", s.healthz) 22 | s.g.GET("/", s.indexRoute) 23 | 24 | g := s.g.Group("/api/v1") 25 | 26 | g.Use(s.authMiddleware()) 27 | 28 | // Rings 29 | g.GET("/rings", s.routeGetRings) 30 | g.GET("/r/:ring", s.routeGetRing) 31 | g.GET("/r/:ring/posts", s.maybeAuthenticatedUser, s.getRingPosts) 32 | g.POST("/r/:ring", s.authenticatedUser, s.routeCreateRing) 33 | 34 | // Posts 35 | g.POST("/posts", s.authenticatedUser, s.createPost) 36 | g.GET("/posts/:id", s.maybeAuthenticatedUser, s.getPost) 37 | g.GET("/posts/:id/comments", s.getComments) 38 | g.POST("/posts/:id/comments", s.postComment) 39 | g.DELETE("/posts/:id/comments/:commentId", s.deleteComment) 40 | 41 | g.PUT("/posts/:id/upvote", s.authenticatedUser, s.routeUpvotePost) 42 | g.PUT("/posts/:id/downvote", s.authenticatedUser, s.routeDownvotePost) 43 | 44 | // Comments 45 | g.GET("/comments", s.routeGetRecentComments) 46 | g.PUT("/posts/:id/comments/:commentId/upvote", s.upvoteComment) 47 | g.PUT("/posts/:id/comments/:commentId/downvote", s.downvoteComment) 48 | 49 | // Users 50 | g.GET("/users/me", s.getMe) 51 | g.GET("/users", s.getUsers) 52 | g.GET("/users/:username", s.getUser) 53 | g.GET("/users/:username/profilePicture", s.getUserProfilePicture) 54 | 55 | // SignUp 56 | g.GET("/signup/usernameAvailability", s.usernameAvailability) 57 | g.POST("/signup/username", s.signupUsername) 58 | 59 | // Reddit-compatible API 60 | s.g.GET("/r/:ring/hot.json", s.getRcRingHot) 61 | s.g.GET("/r/:ring/about.json", s.getRcRingAbout) 62 | s.g.GET("/comments/:id", s.getRcComments) 63 | s.g.GET("/subreddits/search.json", s.getRcRingsSearch) 64 | 65 | // Users 66 | g.GET("/u/:username", s.getUser) 67 | 68 | } 69 | -------------------------------------------------------------------------------- /pkg/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | authenticator "backend/pkg/platform/auth" 6 | "backend/pkg/reddit_compat" 7 | "backend/pkg/request" 8 | "backend/pkg/response" 9 | "context" 10 | "errors" 11 | "fmt" 12 | "github.com/coreos/go-oidc/v3/oidc" 13 | "github.com/gin-gonic/gin" 14 | "github.com/sirupsen/logrus" 15 | "gorm.io/driver/postgres" 16 | "gorm.io/gorm" 17 | "gorm.io/gorm/clause" 18 | "gorm.io/gorm/logger" 19 | "log" 20 | "net/http" 21 | "os" 22 | "reflect" 23 | "strings" 24 | "time" 25 | "unicode" 26 | ) 27 | 28 | type Server struct { 29 | g *gin.Engine 30 | db *gorm.DB 31 | logger *logrus.Logger 32 | baseUrl string 33 | authProvider *authenticator.Authenticator 34 | } 35 | 36 | type Auth0Config struct { 37 | Domain string 38 | ClientId string 39 | ClientSecret string 40 | } 41 | 42 | func New(dsn string, auth0Config *Auth0Config, baseUrl string) (*Server, error) { 43 | gormLogger := logger.New( 44 | log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer 45 | logger.Config{ 46 | SlowThreshold: time.Second, // Slow SQL threshold 47 | LogLevel: logger.Info, // Log level 48 | IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger 49 | ParameterizedQueries: false, // Don't include params in the SQL log 50 | Colorful: false, // Disable color 51 | }, 52 | ) 53 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 54 | Logger: gormLogger, 55 | }) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | s := Server{ 61 | g: gin.New(), 62 | db: db, 63 | logger: logrus.New(), 64 | baseUrl: baseUrl, 65 | } 66 | if auth0Config != nil { 67 | authProvider, err := authenticator.New(auth0Config.Domain, auth0Config.ClientId, auth0Config.ClientSecret) 68 | if err != nil { 69 | return nil, fmt.Errorf("unable to create authentication provider: %v", err) 70 | } 71 | s.authProvider = authProvider 72 | } 73 | 74 | s.initRoutes() 75 | err = s.initModels() 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | // s.fillTestData() 81 | return &s, nil 82 | } 83 | 84 | func (s *Server) SetLogger(logger *logrus.Logger) { 85 | if logger != nil { 86 | s.logger = logger 87 | } 88 | } 89 | 90 | func (s *Server) Run(addr string) error { 91 | return s.g.Run(addr) 92 | } 93 | 94 | func (s *Server) healthz(context *gin.Context) { 95 | context.JSON(200, gin.H{ 96 | "status": "ok", 97 | }) 98 | } 99 | 100 | func validateRingName(name string) bool { 101 | if len(name) < 3 || len(name) > 20 { 102 | return false 103 | } 104 | 105 | // Make sure the ring name is lowercase and can only contain 106 | // letters, numbers, and underscores 107 | for _, c := range name { 108 | if !unicode.IsLetter(c) && !unicode.IsNumber(c) && c != '_' { 109 | return false 110 | } 111 | } 112 | 113 | return true 114 | } 115 | 116 | func (s *Server) initModels() error { 117 | // Auto-migrate all the models in `models` 118 | // Check if the comment_action enum exists 119 | err := s.createCommentAction() 120 | if err != nil { 121 | return err 122 | } 123 | 124 | err = s.createPostAction() 125 | if err != nil { 126 | return err 127 | } 128 | 129 | return s.db.AutoMigrate( 130 | &models.Comment{}, 131 | &models.Post{}, 132 | &models.Ring{}, 133 | &models.User{}, 134 | &models.SocialLink{}, 135 | &models.CommentAction{}, 136 | &models.PostAction{}, 137 | ) 138 | } 139 | 140 | func (s *Server) createCommentAction() error { 141 | var commentActionExists bool 142 | tx := s.db. 143 | Raw("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'comment_action')"). 144 | Scan(&commentActionExists) 145 | if tx.Error != nil { 146 | return tx.Error 147 | } 148 | 149 | if !commentActionExists { 150 | tx = s.db.Exec("CREATE TYPE comment_action AS ENUM ('upvote', 'downvote');") 151 | if tx.Error != nil { 152 | return tx.Error 153 | } 154 | } 155 | return nil 156 | } 157 | func (s *Server) createPostAction() error { 158 | var exists bool 159 | tx := s.db. 160 | Raw("SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'post_action')"). 161 | Scan(&exists) 162 | if tx.Error != nil { 163 | return tx.Error 164 | } 165 | 166 | if !exists { 167 | tx = s.db.Exec("CREATE TYPE post_action AS ENUM ('upvote', 'downvote');") 168 | if tx.Error != nil { 169 | return tx.Error 170 | } 171 | } 172 | return nil 173 | } 174 | 175 | func (s *Server) getRingPosts(context *gin.Context) { 176 | // Gets the posts in ring, sorted by score 177 | ringName := context.Param("ring") 178 | if ringName == "" { 179 | context.AbortWithStatusJSON(400, gin.H{ 180 | "error": "Ring name is required", 181 | }) 182 | return 183 | } 184 | 185 | // Get ring 186 | r, err := s.repoRingAbout(ringName) 187 | if err != nil { 188 | s.logger.Errorf("Unable to get ring %s: %v", ringName, err) 189 | internalServerError(context) 190 | return 191 | } 192 | 193 | var posts []models.Post 194 | if ringName == "popular" { 195 | posts, err = s.handlePopular() 196 | if err != nil { 197 | s.logger.Errorf("Unable to get popular posts: %v", err) 198 | internalServerError(context) 199 | return 200 | } 201 | } else { 202 | tx := s.db. 203 | Preload("Author"). 204 | Limit(100). 205 | Order("score desc, created_at DESC"). 206 | Find(&posts, "ring_name = ?", ringName) 207 | if tx.Error != nil { 208 | s.logger.Errorf("Unable to get posts for %s: %v", ringName, tx.Error) 209 | context.AbortWithStatusJSON(500, gin.H{ 210 | "error": "Unable to get posts", 211 | }) 212 | return 213 | } 214 | } 215 | 216 | // Get the user's vote on each post 217 | if context.GetString("username") != "" { 218 | // Get a list of IDs for the posts 219 | var postIds []uint 220 | for _, p := range posts { 221 | postIds = append(postIds, p.ID) 222 | } 223 | 224 | // Get votes for the posts 225 | var votes []models.PostAction 226 | tx := s.db. 227 | Where("post_id IN ?", postIds). 228 | Where("username = ?", context.GetString("username")). 229 | Find(&votes) 230 | if tx.Error != nil { 231 | s.logger.Errorf("Unable to get votes for posts: %v", tx.Error) 232 | internalServerError(context) 233 | return 234 | } 235 | 236 | // Add votes to map 237 | votesMap := make(map[uint]models.PostAction) 238 | for _, v := range votes { 239 | votesMap[v.PostId] = v 240 | } 241 | 242 | // Add votes to posts 243 | for i, p := range posts { 244 | if v, ok := votesMap[p.ID]; ok { 245 | posts[i].VotedUp = v.Action == models.ActionUpvote 246 | posts[i].VotedDown = v.Action == models.ActionDownvote 247 | } 248 | } 249 | } 250 | 251 | context.JSON(200, convertResponsePosts(posts, r)) 252 | } 253 | 254 | func (s *Server) getMe(c *gin.Context) { 255 | idToken, done := s.idToken(c) 256 | if done { 257 | return 258 | } 259 | 260 | user, err := s.repoGetUserByAuthSubject(idToken.Subject) 261 | if err != nil { 262 | s.handleUserError(c, err) 263 | return 264 | } 265 | c.JSON(200, user) 266 | 267 | } 268 | 269 | func (s *Server) idToken(c *gin.Context) (*oidc.IDToken, bool) { 270 | v, exists := c.Get("id_token") 271 | if !exists { 272 | c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ 273 | "error": "You must be authenticated to use this endpoint", 274 | }) 275 | return nil, true 276 | } 277 | 278 | idToken, ok := v.(*oidc.IDToken) 279 | if !ok { 280 | s.logger.Errorf("Unable to cast id_token to *oidc.IDToken") 281 | internalServerError(c) 282 | return nil, true 283 | } 284 | return idToken, false 285 | } 286 | 287 | func (s *Server) getUser(context *gin.Context) { 288 | username := context.Param("username") 289 | if username == "" { 290 | context.AbortWithStatusJSON(400, gin.H{ 291 | "error": "Username is required", 292 | }) 293 | return 294 | } 295 | 296 | user, err := s.repoGetUserByUsername(username) 297 | if err != nil { 298 | s.handleUserError(context, err) 299 | return 300 | } 301 | context.JSON(200, user) 302 | } 303 | 304 | func (s *Server) handleUserError(context *gin.Context, err error) { 305 | if err == gorm.ErrRecordNotFound { 306 | context.AbortWithStatusJSON(404, gin.H{ 307 | "error": "User not found", 308 | }) 309 | return 310 | } 311 | s.logger.Errorf("Unable to get user: %v", err) 312 | context.AbortWithStatusJSON(500, gin.H{ 313 | "error": "Unable to get user", 314 | }) 315 | return 316 | } 317 | 318 | func (s *Server) repoGetUserByUsername(username string) (models.User, error) { 319 | var user models.User 320 | tx := s.db. 321 | Preload("SocialLinks"). 322 | Preload("Badges"). 323 | First(&user, "username = ?", username) 324 | return user, tx.Error 325 | } 326 | 327 | func (s *Server) getUserProfilePicture(context *gin.Context) { 328 | username := context.Param("username") 329 | if username == "" { 330 | context.AbortWithStatusJSON(400, gin.H{ 331 | "error": "Username is required", 332 | }) 333 | return 334 | } 335 | 336 | var user models.User 337 | tx := s.db.First(&user, "username = ?", username) 338 | if tx.Error != nil { 339 | if tx.Error == gorm.ErrRecordNotFound { 340 | context.AbortWithStatusJSON(404, gin.H{ 341 | "error": "User not found", 342 | }) 343 | return 344 | } 345 | s.logger.Errorf("Unable to get user %s: %v", username, tx.Error) 346 | context.AbortWithStatusJSON(500, gin.H{ 347 | "error": "Unable to get user", 348 | }) 349 | return 350 | } 351 | 352 | if user.ProfilePicture == nil { 353 | context.Redirect(302, s.baseUrl+"/default-profile-picture.jpg") 354 | return 355 | } 356 | 357 | context.Redirect(302, *user.ProfilePicture) 358 | } 359 | 360 | func (s *Server) getRcRingHot(context *gin.Context) { 361 | ringName := context.Param("ring") 362 | after := context.Query("after") 363 | 364 | if after != "" { 365 | s.convertToRedditPosts(context, []models.Post{}) 366 | return 367 | } 368 | 369 | posts, err := s.repoRingPosts(ringName) 370 | if err != nil { 371 | if errors.Is(err, gorm.ErrRecordNotFound) { 372 | context.AbortWithStatusJSON(404, gin.H{ 373 | "error": "Ring not found", 374 | }) 375 | return 376 | } 377 | s.logger.Errorf("Unable to get posts for %s: %v", ringName, err) 378 | context.AbortWithStatusJSON(500, gin.H{ 379 | "error": "Unable to get posts", 380 | }) 381 | return 382 | } 383 | 384 | s.convertToRedditPosts(context, posts) 385 | } 386 | 387 | func (s *Server) convertToRedditPosts(context *gin.Context, posts []models.Post) { 388 | // Convert to Reddit-compatible format 389 | listing, err := toRedditPosts(posts, s.baseUrl) 390 | if err != nil { 391 | s.logger.Errorf("unable to convert posts: %v", err) 392 | internalServerError(context) 393 | return 394 | } 395 | 396 | if listing.Data.Children == nil { 397 | listing.Data.Children = []reddit_compat.KindData[reddit_compat.Post]{} 398 | } 399 | 400 | context.JSON(200, listing) 401 | } 402 | 403 | func (s *Server) getRcRingsSearch(context *gin.Context) { 404 | q := context.Query("q") 405 | if q == "" { 406 | context.AbortWithStatusJSON(400, gin.H{ 407 | "error": "q is required", 408 | }) 409 | return 410 | } 411 | 412 | nsfwQuery := context.Query("include_over_18") 413 | includeNsfw := false 414 | if nsfwQuery == "1" { 415 | includeNsfw = true 416 | } 417 | 418 | rings, err := s.repoRingsSearch(q, includeNsfw, "", 25) 419 | if err != nil { 420 | s.logger.Errorf("unable to search rings: %v", err) 421 | internalServerError(context) 422 | return 423 | } 424 | 425 | // Convert to Reddit-compatible format 426 | listing, err := toRedditSubreddits(rings) 427 | if err != nil { 428 | s.logger.Errorf("unable to convert rings: %v", err) 429 | internalServerError(context) 430 | return 431 | } 432 | 433 | context.JSON(200, listing) 434 | } 435 | 436 | func (s *Server) authMiddleware() gin.HandlerFunc { 437 | return func(c *gin.Context) { 438 | if c.Request.Header.Get("Authorization") == "" { 439 | return 440 | } 441 | 442 | if strings.HasPrefix(c.Request.Header.Get("Authorization"), "Bearer ") { 443 | if s.authProvider == nil { 444 | s.logger.Warnf("running without auth provider, but auth token provided") 445 | return 446 | } 447 | 448 | // Parse Bearer token 449 | token := strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer ") 450 | idToken, err := s.authProvider.VerifyToken(context.Background(), token) 451 | if err != nil { 452 | s.logger.Errorf("Unable to verify ID token: %v", err) 453 | c.AbortWithStatusJSON(401, gin.H{ 454 | "error": "Unable to verify ID token", 455 | }) 456 | return 457 | } 458 | 459 | c.Set("id_token", idToken) 460 | } 461 | } 462 | } 463 | 464 | func (s *Server) usernameAvailability(c *gin.Context) { 465 | usernameQuery := c.Query("username") 466 | valid, err := validateUsername(usernameQuery) 467 | if !valid { 468 | c.AbortWithStatusJSON(400, gin.H{ 469 | "error": err, 470 | }) 471 | return 472 | } 473 | 474 | // Check if username is available 475 | tx := s.db.First(&models.User{}, "username = ?", usernameQuery) 476 | if tx.Error != nil { 477 | if tx.Error == gorm.ErrRecordNotFound { 478 | c.JSON(200, gin.H{ 479 | "available": true, 480 | }) 481 | return 482 | } 483 | s.logger.Errorf("Unable to check username availability: %v", tx.Error) 484 | c.AbortWithStatusJSON(500, gin.H{ 485 | "error": "Unable to check username availability", 486 | }) 487 | return 488 | } 489 | 490 | c.JSON(200, gin.H{ 491 | "available": false, 492 | }) 493 | } 494 | 495 | // signupUsername creates a user with the given username 496 | // and associates it with the ID token 497 | // It expects the username to be passed as a JSON body 498 | func (s *Server) signupUsername(c *gin.Context) { 499 | idToken, done := s.idToken(c) 500 | if done { 501 | return 502 | } 503 | 504 | var request struct { 505 | Username string `json:"username"` 506 | } 507 | err := c.BindJSON(&request) 508 | if err != nil { 509 | c.AbortWithStatusJSON(400, gin.H{ 510 | "error": "Username is required", 511 | }) 512 | return 513 | } 514 | 515 | valid, errMsg := validateUsername(request.Username) 516 | if !valid { 517 | c.AbortWithStatusJSON(400, gin.H{ 518 | "error": errMsg, 519 | }) 520 | return 521 | } 522 | 523 | // Create a user with the username 524 | user := models.User{ 525 | Username: request.Username, 526 | AuthSubject: &idToken.Subject, 527 | } 528 | tx := s.db.Create(&user) 529 | if tx.Error != nil { 530 | s.logger.Errorf("Unable to create user: %v", tx.Error) 531 | c.AbortWithStatusJSON(500, gin.H{ 532 | "error": "Unable to create user", 533 | }) 534 | return 535 | } 536 | c.JSON(http.StatusOK, user) 537 | } 538 | 539 | func (s *Server) usernameForIdToken(token *oidc.IDToken) (string, error) { 540 | if token == nil { 541 | return "", fmt.Errorf("token is nil") 542 | } 543 | var user models.User 544 | tx := s.db.First(&user, "auth_subject = ?", token.Subject) 545 | if tx.Error != nil { 546 | return "", tx.Error 547 | } 548 | return user.Username, nil 549 | } 550 | 551 | func (s *Server) addComment(postId uint, username string, request request.Comment) (models.Comment, error) { 552 | // TODO: Check if user can comment here 553 | comment := models.Comment{ 554 | PostId: postId, 555 | Body: request.Content, 556 | AuthorUsername: username, 557 | ParentId: request.ParentId, 558 | } 559 | 560 | if request.ParentId != nil { 561 | // Get original parent comment 562 | var parentComment models.Comment 563 | tx := s.db.First(&parentComment, "id = ?", request.ParentId) 564 | if tx.Error != nil { 565 | return comment, tx.Error 566 | } 567 | 568 | comment.Depth = parentComment.Depth + 1 569 | } 570 | 571 | tx := s.db.Create(&comment) 572 | if tx.Error == nil { 573 | // Add +1 to comment count in post 574 | tx = s.db.Model(&models.Post{}). 575 | Where("id = ?", postId). 576 | Update("comments_count", 577 | gorm.Expr("comments_count + ?", 1), 578 | ) 579 | if tx.Error != nil { 580 | return comment, tx.Error 581 | } 582 | 583 | // Fetch comment with Preload("Author") 584 | tx = s.db.Preload("Author").First(&comment, comment.ID) 585 | } 586 | return comment, tx.Error 587 | } 588 | 589 | func (s *Server) isAdmin(username string) bool { 590 | var user models.User 591 | tx := s.db.First(&user, "username = ?", username) 592 | if tx.Error != nil { 593 | // Cannot be found / other error 594 | return false 595 | } 596 | 597 | return user.Admin 598 | } 599 | 600 | func (s *Server) repoCommentVoteAction(commentId uint, username string, action models.VoteAction) (models.CommentAction, int, error) { 601 | commentAction := models.CommentAction{ 602 | Username: username, 603 | CommentId: commentId, 604 | Action: action, 605 | } 606 | addScore := 0 607 | 608 | // Check if user has already voted 609 | var existingAction models.CommentAction 610 | tx := s.db.First(&existingAction, "username = ? AND comment_id = ?", username, commentId) 611 | if tx.Error == nil { 612 | if action == existingAction.Action { 613 | // User has already voted 614 | return commentAction, 0, fmt.Errorf("user has already voted") 615 | } 616 | 617 | // User has changed their vote 618 | if existingAction.Action == models.ActionUpvote && action == models.ActionDownvote { 619 | addScore = -2 620 | } else if existingAction.Action == models.ActionDownvote && action == models.ActionUpvote { 621 | addScore = 2 622 | } 623 | } else { 624 | if errors.Is(tx.Error, gorm.ErrRecordNotFound) { 625 | if action == models.ActionUpvote { 626 | addScore = 1 627 | } else if action == models.ActionDownvote { 628 | addScore = -1 629 | } 630 | } 631 | } 632 | 633 | tx = s.db.Clauses( 634 | clause.OnConflict{ 635 | Columns: []clause.Column{{Name: "username"}, {Name: "comment_id"}}, 636 | DoUpdates: clause.AssignmentColumns([]string{"action"}), 637 | }, 638 | ).Create(&commentAction) 639 | return commentAction, addScore, tx.Error 640 | } 641 | 642 | func (s *Server) repoDownvoteComment(commentId uint, username string) (models.CommentAction, error) { 643 | commentAction := models.CommentAction{ 644 | Username: username, 645 | CommentId: commentId, 646 | Action: models.ActionDownvote, 647 | } 648 | tx := s.db.Clauses( 649 | clause.OnConflict{ 650 | Columns: []clause.Column{{Name: "username"}, {Name: "comment_id"}}, 651 | DoUpdates: clause.AssignmentColumns([]string{"action"}), 652 | }, 653 | ).Create(&commentAction) 654 | return commentAction, tx.Error 655 | } 656 | 657 | func (s *Server) repoIncreaseCommentScore(commentId uint, amount int) error { 658 | tx := s.db.Model(&models.Comment{}). 659 | Where("id = ?", commentId). 660 | UpdateColumn("score", gorm.Expr("score + ?", amount)) 661 | return tx.Error 662 | } 663 | 664 | func (s *Server) hasIdToken(c *gin.Context) bool { 665 | _, exists := c.Get("id_token") 666 | return exists 667 | } 668 | 669 | func (s *Server) repoCommentActions(username string, postId int64) []models.CommentAction { 670 | var commentActions []models.CommentAction 671 | tx := s.db.Model(&models.CommentAction{}). 672 | Where("username = ?", username). 673 | Joins("JOIN comments ON comments.id = comment_actions.comment_id"). 674 | Where("comments.post_id = ?", postId). 675 | Find(&commentActions) 676 | if tx.Error != nil { 677 | return []models.CommentAction{} 678 | } 679 | return commentActions 680 | } 681 | 682 | // getUsers returns a paginated list of users 683 | func (s *Server) getUsers(c *gin.Context) { 684 | var users []models.User 685 | var count int64 686 | pagination, done := s.getPagination(c) 687 | if done { 688 | return 689 | } 690 | 691 | tx := s.db. 692 | Model(&models.User{}). 693 | Where("deleted_at IS NULL"). 694 | Count(&count) 695 | if tx.Error != nil { 696 | s.logger.Errorf("Failed to get users: %v", tx.Error) 697 | c.JSON(http.StatusInternalServerError, gin.H{ 698 | "error": "Failed to get users", 699 | }) 700 | return 701 | } 702 | 703 | tx = s.db. 704 | Preload("SocialLinks"). 705 | Order("username ASC"). 706 | Limit(int(pagination.Limit)). 707 | Where("username > ? AND deleted_at IS NULL", pagination.After). 708 | Find(&users) 709 | if tx.Error != nil { 710 | s.logger.Errorf("Failed to get users: %v", tx.Error) 711 | c.JSON(http.StatusInternalServerError, gin.H{ 712 | "error": "Failed to get users", 713 | }) 714 | return 715 | } 716 | 717 | responseUsers := cleanupUsers(users) 718 | outputPagination := response.Paginated[models.User]{ 719 | Items: responseUsers, 720 | Total: count, 721 | } 722 | if len(responseUsers) > 0 { 723 | outputPagination.After = responseUsers[len(responseUsers)-1].Username 724 | } 725 | 726 | c.JSON(http.StatusOK, outputPagination) 727 | } 728 | 729 | func (s *Server) repoRecentComments(after *uint64) ([]models.Comment, error) { 730 | var comments []models.Comment 731 | tx := s.db. 732 | Preload("Post"). 733 | Preload("Author"). 734 | Order("created_at DESC") 735 | 736 | if after != nil { 737 | tx = tx.Where("id < ?", *after) 738 | } 739 | 740 | tx. 741 | Limit(5). 742 | Find(&comments) 743 | return comments, tx.Error 744 | } 745 | 746 | func (s *Server) repoIncreasePostScore(tx *gorm.DB, id uint, value int) error { 747 | tx = tx.Model(&models.Post{}). 748 | Where("id = ?", id). 749 | UpdateColumn("score", gorm.Expr("score + ?", value)) 750 | return tx.Error 751 | } 752 | 753 | func (s *Server) repoPostAction(postId uint, username string) (*models.PostAction, error) { 754 | var postAction models.PostAction 755 | tx := s.db. 756 | Where("post_id = ? AND username = ?", postId, username). 757 | First(&postAction) 758 | if tx.Error != nil { 759 | return nil, tx.Error 760 | } 761 | return &postAction, nil 762 | } 763 | 764 | // handlePopular returns a list of popular posts for the frontpage 765 | func (s *Server) handlePopular() ([]models.Post, error) { 766 | threshold := time.Hour * 24 * 365 767 | minPublishDate := time.Now().Add(-threshold) 768 | var posts []models.Post 769 | err := s.db. 770 | Preload("Author"). 771 | Preload("Ring"). 772 | Where("created_at > ?", minPublishDate). 773 | Order("(score / extract(epoch from (now() - created_at))) DESC, score DESC, comments_count, created_at DESC"). 774 | Limit(100). 775 | Find(&posts).Error 776 | if err != nil { 777 | return nil, err 778 | } 779 | 780 | return posts, nil 781 | } 782 | 783 | func parseNilAsEmpty[T any](element T) T { 784 | // Given a RedditPosts struct, parse the struct tag for the `json` key and check if it does 785 | // have the `nilasempty` key. If it does, then set the value to an empty array. 786 | // This is needed because Reddit expects an empty array instead of null for some fields. 787 | 788 | t := reflect.TypeOf(element).Elem() 789 | v := reflect.ValueOf(element).Elem() 790 | num := t.NumField() 791 | // Iterate over the fields 792 | for i := 0; i < num; i++ { 793 | // Get the field 794 | field := t.Field(i) 795 | // Get the value of the field 796 | value := v.Field(i) 797 | // Get the json tag 798 | tag := field.Tag.Get("json") 799 | // Check if the tag has the `nilasempty` key 800 | if strings.Contains(tag, "nilasempty") { 801 | value.Set(reflect.MakeSlice(value.Type(), 0, 0)) 802 | } 803 | } 804 | return element 805 | } 806 | 807 | func internalServerError(context *gin.Context) { 808 | context.AbortWithStatusJSON(500, gin.H{ 809 | "error": "Internal server error", 810 | }) 811 | } 812 | 813 | func badRequest(context *gin.Context) { 814 | context.AbortWithStatusJSON(400, gin.H{ 815 | "error": "Bad request", 816 | }) 817 | } 818 | -------------------------------------------------------------------------------- /pkg/server_post.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "backend/pkg/models" 5 | "errors" 6 | "github.com/gin-gonic/gin" 7 | "gorm.io/gorm" 8 | "strconv" 9 | ) 10 | 11 | func (s *Server) getPost(c *gin.Context) { 12 | id, done := parsePostId(c) 13 | if done { 14 | return 15 | } 16 | 17 | post, err := s.repoPost(uint(id)) 18 | if errors.Is(err, gorm.ErrRecordNotFound) { 19 | c.JSON(404, gin.H{"error": "post not found"}) 20 | return 21 | } 22 | 23 | if err != nil { 24 | s.logger.Errorf("unable to get post %d: %v", id, err) 25 | internalServerError(c) 26 | return 27 | } 28 | 29 | // If user is logged, check if user has upvoted this post 30 | username := c.GetString("username") 31 | if username != "" { 32 | action, err := s.repoPostAction(uint(id), username) 33 | if err != nil { 34 | if errors.Is(err, gorm.ErrRecordNotFound) { 35 | // User hasn't voted this post 36 | c.JSON(200, post) 37 | return 38 | } else { 39 | s.logger.Errorf("unable to check if user %s has upvoted post %d: %v", username, id, err) 40 | internalServerError(c) 41 | return 42 | } 43 | } 44 | post.VotedUp = action.Action == models.ActionUpvote 45 | post.VotedDown = action.Action == models.ActionDownvote 46 | } 47 | 48 | c.JSON(200, post) 49 | } 50 | 51 | func parsePostId(c *gin.Context) (int64, bool) { 52 | return parseId(c, "id") 53 | } 54 | func parseId(c *gin.Context, name string) (int64, bool) { 55 | idParam := c.Param(name) 56 | if idParam == "" { 57 | c.JSON(400, gin.H{"error": name + " is required"}) 58 | return 0, true 59 | } 60 | 61 | id, err := strconv.ParseInt(idParam, 10, 64) 62 | if err != nil { 63 | c.JSON(400, gin.H{"error": name + " must be a number"}) 64 | return 0, true 65 | } 66 | 67 | if id < 0 { 68 | c.JSON(400, gin.H{"error": name + " must be a positive number"}) 69 | return 0, true 70 | } 71 | return id, false 72 | } 73 | -------------------------------------------------------------------------------- /pkg/test_utils.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func testGetNewServer(t *testing.T) *Server { 9 | dbUrl := os.Getenv("DATABASE_URL") 10 | if dbUrl == "" { 11 | dbUrl = "host=localhost user=ring password=ring dbname=ring port=5432" 12 | } 13 | s, err := New(dbUrl, nil, "http://localhost:8081") 14 | if err != nil { 15 | t.Fatalf("unable to create server: %v", err) 16 | } 17 | 18 | return s 19 | } 20 | -------------------------------------------------------------------------------- /pkg/validation.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "regexp" 4 | 5 | func validateUsername(username string) (bool, string) { 6 | if len(username) < 4 { 7 | return false, "Username must be at least 4 characters long" 8 | } 9 | 10 | if len(username) > 20 { 11 | return false, "Username must be at most 20 characters long" 12 | } 13 | 14 | // Make sure it's only alphanumeric, numbers, and underscores 15 | m, err := regexp.MatchString("^[a-zA-Z0-9_]*$", username) 16 | if err != nil { 17 | return false, "Unable to validate username" 18 | } 19 | 20 | if !m { 21 | return false, "Username must only contain letters, numbers, and underscores" 22 | } 23 | 24 | invalidUsernames := []string{ 25 | "admin", 26 | "system", 27 | "root", 28 | "moderator", 29 | "mod", 30 | "administrator", 31 | "me", 32 | } 33 | 34 | for _, invalidUsername := range invalidUsernames { 35 | if username == invalidUsername { 36 | return false, "Username is not allowed" 37 | } 38 | } 39 | return true, "" 40 | } 41 | --------------------------------------------------------------------------------