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