From fb01bbb290d37f491d603bdf015d994a39e153c7 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 21 Apr 2025 10:26:21 +0200 Subject: [PATCH 001/144] Init --- .formatter.exs | 6 + .gitignore | 37 + LICENSE | 232 ++++++ README.md | 58 ++ assets/css/app.css | 5 + assets/js/app.js | 44 ++ assets/tailwind.config.js | 74 ++ assets/vendor/topbar.js | 165 +++++ config/config.exs | 66 ++ config/dev.exs | 85 +++ config/prod.exs | 20 + config/runtime.exs | 117 +++ config/test.exs | 37 + lib/nulla.ex | 9 + lib/nulla/application.ex | 36 + lib/nulla/mailer.ex | 3 + lib/nulla/repo.ex | 5 + lib/nulla_web.ex | 116 +++ lib/nulla_web/components/core_components.ex | 676 ++++++++++++++++++ lib/nulla_web/components/layouts.ex | 14 + .../components/layouts/app.html.heex | 32 + .../components/layouts/root.html.heex | 17 + lib/nulla_web/controllers/error_html.ex | 24 + lib/nulla_web/controllers/error_json.ex | 21 + lib/nulla_web/controllers/page_controller.ex | 9 + lib/nulla_web/controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 222 ++++++ lib/nulla_web/endpoint.ex | 53 ++ lib/nulla_web/gettext.ex | 25 + lib/nulla_web/router.ex | 44 ++ lib/nulla_web/telemetry.ex | 93 +++ mix.exs | 85 +++ mix.lock | 41 ++ priv/gettext/en/LC_MESSAGES/errors.po | 112 +++ priv/gettext/errors.pot | 109 +++ priv/repo/migrations/.formatter.exs | 4 + priv/repo/seeds.exs | 11 + priv/static/favicon.ico | Bin 0 -> 152 bytes priv/static/images/logo.svg | 6 + priv/static/robots.txt | 5 + .../nulla_web/controllers/error_html_test.exs | 14 + .../nulla_web/controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + test/support/conn_case.ex | 38 + test/support/data_case.ex | 58 ++ test/test_helper.exs | 2 + 46 files changed, 2860 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/tailwind.config.js create mode 100644 assets/vendor/topbar.js create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/nulla.ex create mode 100644 lib/nulla/application.ex create mode 100644 lib/nulla/mailer.ex create mode 100644 lib/nulla/repo.ex create mode 100644 lib/nulla_web.ex create mode 100644 lib/nulla_web/components/core_components.ex create mode 100644 lib/nulla_web/components/layouts.ex create mode 100644 lib/nulla_web/components/layouts/app.html.heex create mode 100644 lib/nulla_web/components/layouts/root.html.heex create mode 100644 lib/nulla_web/controllers/error_html.ex create mode 100644 lib/nulla_web/controllers/error_json.ex create mode 100644 lib/nulla_web/controllers/page_controller.ex create mode 100644 lib/nulla_web/controllers/page_html.ex create mode 100644 lib/nulla_web/controllers/page_html/home.html.heex create mode 100644 lib/nulla_web/endpoint.ex create mode 100644 lib/nulla_web/gettext.ex create mode 100644 lib/nulla_web/router.ex create mode 100644 lib/nulla_web/telemetry.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/images/logo.svg create mode 100644 priv/static/robots.txt create mode 100644 test/nulla_web/controllers/error_html_test.exs create mode 100644 test/nulla_web/controllers/error_json_test.exs create mode 100644 test/nulla_web/controllers/page_controller_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3111c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +nulla-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ea81f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the Program. + +To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. + + nulla + Copyright (C) 2025 nulla + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + nulla Copyright (C) 2025 nulla + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/README.md b/README.md new file mode 100644 index 0000000..2003a77 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Nulla + +Agnostic Social Network + +## TODO + +Stack: Elixir + Phoenix + PostgreSQL + +* Lightweight web interface +* API compatible with other ActivityPub instances +* Groups +* Formatting: Text / Markdown / HTML +* Links preview +* Timelines: Home / Local / Global / Custom +* Global search +* Bookmarks +* Profile links verification +* Ordering profile links +* Import/Export posts +* Sync settings on server + +### Configuration + +* Post preview +* Character limit per post +* Enable/Disable custom emoji for whole instance + +### User settings + +#### Profile + +* Avatar +* Banner +* Name +* Bio +* Location +* Birthday +* Links +* Follow requests approval +* Toggle reacts under own posts +* Toggle view of reacts under posts +* Profile migration + +#### Security + +* Change password +* Token +* Enable/Disable email login notifications +* Enable/Disable IP address log + +| IP | Datetime | +| --------------------------------------| +| 127.0.0.1 | 2025-01-21 00:00:00 | +| 127.127.127.127 | 2025-02-21 00:00:00 | + +* Clear IP address log + +#### Filters diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..d5e278a --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,44 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..0d71fd9 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,74 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/nulla_web.ex", + "../lib/nulla_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..52e443a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,66 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :nulla, + ecto_repos: [Nulla.Repo], + generators: [timestamp_type: :utc_datetime] + +# Configures the endpoint +config :nulla, NullaWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON], + layout: false + ], + pubsub_server: Nulla.PubSub, + live_view: [signing_salt: "jcAt5/U+"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :nulla, Nulla.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + nulla: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + nulla: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..31508fc --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,85 @@ +import Config + +# Configure your database +config :nulla, Nulla.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "nulla_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :nulla, NullaWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "VfFSj33PMul7V6oKoeanGdabenUTRUabPkosFKft2PqKMlMKPW5s7Ls0OtFcgSSO", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:nulla, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:nulla, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :nulla, NullaWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/nulla_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :nulla, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d52a3f9 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,20 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :nulla, NullaWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Nulla.Finch + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..0a45eea --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,117 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/nulla start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :nulla, NullaWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :nulla, Nulla.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :nulla, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :nulla, NullaWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :nulla, NullaWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :nulla, NullaWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :nulla, Nulla.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..ece2ea6 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,37 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :nulla, Nulla.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "nulla_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :nulla, NullaWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "En2KMJ4iAUgGiOa8ADt/0nmQ6yC63U2COOGVSXR+A9nENgS1+6O2HjzwAseCSBCW", + server: false + +# In test we don't send emails +config :nulla, Nulla.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/lib/nulla.ex b/lib/nulla.ex new file mode 100644 index 0000000..ceb0132 --- /dev/null +++ b/lib/nulla.ex @@ -0,0 +1,9 @@ +defmodule Nulla do + @moduledoc """ + Nulla keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex new file mode 100644 index 0000000..7e86858 --- /dev/null +++ b/lib/nulla/application.ex @@ -0,0 +1,36 @@ +defmodule Nulla.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + NullaWeb.Telemetry, + Nulla.Repo, + {DNSCluster, query: Application.get_env(:nulla, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Nulla.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: Nulla.Finch}, + # Start a worker by calling: Nulla.Worker.start_link(arg) + # {Nulla.Worker, arg}, + # Start to serve requests, typically the last entry + NullaWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Nulla.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + NullaWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/nulla/mailer.ex b/lib/nulla/mailer.ex new file mode 100644 index 0000000..53f4221 --- /dev/null +++ b/lib/nulla/mailer.ex @@ -0,0 +1,3 @@ +defmodule Nulla.Mailer do + use Swoosh.Mailer, otp_app: :nulla +end diff --git a/lib/nulla/repo.ex b/lib/nulla/repo.ex new file mode 100644 index 0000000..76b8b7a --- /dev/null +++ b/lib/nulla/repo.ex @@ -0,0 +1,5 @@ +defmodule Nulla.Repo do + use Ecto.Repo, + otp_app: :nulla, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex new file mode 100644 index 0000000..862aea6 --- /dev/null +++ b/lib/nulla_web.ex @@ -0,0 +1,116 @@ +defmodule NullaWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use NullaWeb, :controller + use NullaWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: NullaWeb.Layouts] + + use Gettext, backend: NullaWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {NullaWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # Translation + use Gettext, backend: NullaWeb.Gettext + + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import NullaWeb.CoreComponents + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: NullaWeb.Endpoint, + router: NullaWeb.Router, + statics: NullaWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/nulla_web/components/core_components.ex b/lib/nulla_web/components/core_components.ex new file mode 100644 index 0000000..3687e1e --- /dev/null +++ b/lib/nulla_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule NullaWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + use Gettext, backend: NullaWeb.Gettext + + alias Phoenix.LiveView.JS + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+
+ + + {render_slot(col, @row_item.(row))} + +
+
+
+ + + {render_slot(action, @row_item.(row))} + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
{item.title}
+
{render_slot(item)}
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + {render_slot(@inner_block)} + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/lib/nulla_web/components/layouts.ex b/lib/nulla_web/components/layouts.ex new file mode 100644 index 0000000..0e335fa --- /dev/null +++ b/lib/nulla_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule NullaWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use NullaWeb, :controller` and + `use NullaWeb, :live_view`. + """ + use NullaWeb, :html + + embed_templates "layouts/*" +end diff --git a/lib/nulla_web/components/layouts/app.html.heex b/lib/nulla_web/components/layouts/app.html.heex new file mode 100644 index 0000000..3b3b607 --- /dev/null +++ b/lib/nulla_web/components/layouts/app.html.heex @@ -0,0 +1,32 @@ +
+
+
+ + + +

+ v{Application.spec(:phoenix, :vsn)} +

+
+ +
+
+
+
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex new file mode 100644 index 0000000..ea6a36d --- /dev/null +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title default="Nulla" suffix=" · Phoenix Framework"> + {assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/lib/nulla_web/controllers/error_html.ex b/lib/nulla_web/controllers/error_html.ex new file mode 100644 index 0000000..1b0e1c8 --- /dev/null +++ b/lib/nulla_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule NullaWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use NullaWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/nulla_web/controllers/error_html/404.html.heex + # * lib/nulla_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/nulla_web/controllers/error_json.ex b/lib/nulla_web/controllers/error_json.ex new file mode 100644 index 0000000..be9d1c3 --- /dev/null +++ b/lib/nulla_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule NullaWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex new file mode 100644 index 0000000..3daa035 --- /dev/null +++ b/lib/nulla_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule NullaWeb.PageController do + use NullaWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/lib/nulla_web/controllers/page_html.ex b/lib/nulla_web/controllers/page_html.ex new file mode 100644 index 0000000..fbb6c6e --- /dev/null +++ b/lib/nulla_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule NullaWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use NullaWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/nulla_web/controllers/page_html/home.html.heex b/lib/nulla_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..d72b03c --- /dev/null +++ b/lib/nulla_web/controllers/page_html/home.html.heex @@ -0,0 +1,222 @@ +<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/lib/nulla_web/endpoint.ex b/lib/nulla_web/endpoint.ex new file mode 100644 index 0000000..ca59adb --- /dev/null +++ b/lib/nulla_web/endpoint.ex @@ -0,0 +1,53 @@ +defmodule NullaWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :nulla + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_nulla_key", + signing_salt: "/+24FZnt", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :nulla, + gzip: false, + only: NullaWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :nulla + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug NullaWeb.Router +end diff --git a/lib/nulla_web/gettext.ex b/lib/nulla_web/gettext.ex new file mode 100644 index 0000000..45a98d5 --- /dev/null +++ b/lib/nulla_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: NullaWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :nulla +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex new file mode 100644 index 0000000..95706c9 --- /dev/null +++ b/lib/nulla_web/router.ex @@ -0,0 +1,44 @@ +defmodule NullaWeb.Router do + use NullaWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {NullaWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", NullaWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", NullaWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:nulla, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: NullaWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/nulla_web/telemetry.ex b/lib/nulla_web/telemetry.ex new file mode 100644 index 0000000..0b8fc96 --- /dev/null +++ b/lib/nulla_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule NullaWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("nulla.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("nulla.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("nulla.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("nulla.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("nulla.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {NullaWeb, :count_users, []} + ] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..09ceeec --- /dev/null +++ b/mix.exs @@ -0,0 +1,85 @@ +defmodule Nulla.MixProject do + use Mix.Project + + def project do + [ + app: :nulla, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Nulla.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.21"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, + {:finch, "~> 0.13"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind nulla", "esbuild nulla"], + "assets.deploy": [ + "tailwind nulla --minify", + "esbuild nulla --minify", + "phx.digest" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..626d6e4 --- /dev/null +++ b/mix.lock @@ -0,0 +1,41 @@ +%{ + "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, + "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.10", "d3d54f751ca538b17313541cabb1ab090a0d26e08ba914b49b6648022fa476f4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13f833a39b1368117e0529c0fe5029930a9bf11e2fb805c2263fcc32950f07a2"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "swoosh": {:hex, :swoosh, "1.18.4", "5f5f325cfbc68d454f1606421f2dd02d1b20fd03e10905e9728b26662ae01f1d", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8b45e6f9109bdf89f3d83f810e0cc97c1c971925e72fc4f47da42959d8487ee"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, + "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..1672d2d --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Nulla.Repo.insert!(%Nulla.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/nulla_web/controllers/error_html_test.exs b/test/nulla_web/controllers/error_html_test.exs new file mode 100644 index 0000000..4406660 --- /dev/null +++ b/test/nulla_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule NullaWeb.ErrorHTMLTest do + use NullaWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(NullaWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(NullaWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/nulla_web/controllers/error_json_test.exs b/test/nulla_web/controllers/error_json_test.exs new file mode 100644 index 0000000..0734e4d --- /dev/null +++ b/test/nulla_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule NullaWeb.ErrorJSONTest do + use NullaWeb.ConnCase, async: true + + test "renders 404" do + assert NullaWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert NullaWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/nulla_web/controllers/page_controller_test.exs b/test/nulla_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..5e8da73 --- /dev/null +++ b/test/nulla_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule NullaWeb.PageControllerTest do + use NullaWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..10ed035 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule NullaWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use NullaWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint NullaWeb.Endpoint + + use NullaWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import NullaWeb.ConnCase + end + end + + setup tags do + Nulla.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..c17e6b7 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule Nulla.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Nulla.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Nulla.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Nulla.DataCase + end + end + + setup tags do + Nulla.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Nulla.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..b0b52da --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Nulla.Repo, :manual) From b9a8be167ea46115017878b57a963d1b9727afb4 Mon Sep 17 00:00:00 2001 From: Mirai Kumiko Date: Mon, 21 Apr 2025 10:28:53 +0200 Subject: [PATCH 002/144] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2003a77..3156310 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Stack: Elixir + Phoenix + PostgreSQL * Enable/Disable IP address log | IP | Datetime | -| --------------------------------------| +| ----------------|---------------------| | 127.0.0.1 | 2025-01-21 00:00:00 | | 127.127.127.127 | 2025-02-21 00:00:00 | From 4643422bed72a7229fd455f8ba8a356c7f219db2 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 21 Apr 2025 10:43:42 +0200 Subject: [PATCH 003/144] Update README.md --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3156310..34cf336 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@ Agnostic Social Network Stack: Elixir + Phoenix + PostgreSQL -* Lightweight web interface -* API compatible with other ActivityPub instances -* Groups -* Formatting: Text / Markdown / HTML -* Links preview -* Timelines: Home / Local / Global / Custom -* Global search -* Bookmarks -* Profile links verification -* Ordering profile links -* Import/Export posts -* Sync settings on server +- [ ] Lightweight web interface +- [ ] API compatible with other ActivityPub instances +- [ ] Groups +- [ ] Formatting: Text / Markdown / HTML +- [ ] Links preview +- [ ] Timelines: Home / Local / Global / Custom +- [ ] Global search +- [ ] Bookmarks +- [ ] Profile links verification +- [ ] Ordering profile links +- [ ] Import/Export posts +- [ ] Sync user settings on the server ### Configuration From 204dd842a35d8a69c9527566dfd538f95d827545 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 21 Apr 2025 20:18:51 +0200 Subject: [PATCH 004/144] Update README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 34cf336..c60d995 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Stack: Elixir + Phoenix + PostgreSQL - [ ] Lightweight web interface - [ ] API compatible with other ActivityPub instances +- [ ] JWT - [ ] Groups - [ ] Formatting: Text / Markdown / HTML - [ ] Links preview @@ -18,6 +19,8 @@ Stack: Elixir + Phoenix + PostgreSQL - [ ] Ordering profile links - [ ] Import/Export posts - [ ] Sync user settings on the server +- [ ] Restricted direct messages +- [ ] Direct messages tab ### Configuration @@ -50,8 +53,8 @@ Stack: Elixir + Phoenix + PostgreSQL | IP | Datetime | | ----------------|---------------------| -| 127.0.0.1 | 2025-01-21 00:00:00 | -| 127.127.127.127 | 2025-02-21 00:00:00 | +| 127.0.0.1 | 2025-01-01 00:00:00 | +| 127.127.127.127 | 2025-02-02 00:00:00 | * Clear IP address log From 7cfc131b28b5dc0315b0aae4dfbb3733d80c9518 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 21 Apr 2025 20:38:02 +0200 Subject: [PATCH 005/144] Add doc files --- CHANGELOG.md | 0 CODE_OF_CONDUCT.md | 132 +++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 3 ++ SECURITY.md | 3 ++ 4 files changed, 138 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67fe8ce --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..183ac3e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Please send your patches via [email](mailto:miraikumiko@disroot.org). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f1ce03c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security + +Please send your bug reports via [email](mailto:miraikumiko@disroot.org). From 0c71e15e91a16d22ad90c21537a1f487facc5e09 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 22 Apr 2025 10:57:39 +0200 Subject: [PATCH 006/144] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c60d995..d658800 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Stack: Elixir + Phoenix + PostgreSQL * Post preview * Character limit per post +* Disk space limit per user * Enable/Disable custom emoji for whole instance ### User settings From 43b231dfca1a06072c67e30c99a47b7814f5fdb6 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 22 Apr 2025 16:51:19 +0200 Subject: [PATCH 007/144] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d658800..62631b6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Stack: Elixir + Phoenix + PostgreSQL * Toggle reacts under own posts * Toggle view of reacts under posts * Profile migration +* Delete account +===== Disk Usage: 100 MB (20%) ===== #### Security From 81205c36fccc4412d5279442ab61e0ccd8140dab Mon Sep 17 00:00:00 2001 From: Mirai Kumiko Date: Tue, 22 Apr 2025 16:52:06 +0200 Subject: [PATCH 008/144] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 62631b6..4acf806 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Stack: Elixir + Phoenix + PostgreSQL * Toggle view of reacts under posts * Profile migration * Delete account + ===== Disk Usage: 100 MB (20%) ===== #### Security From 5dd1fe97599175b86f0f8934905831c849603213 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 23 Apr 2025 17:28:19 +0200 Subject: [PATCH 009/144] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4acf806..e8b50b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Nulla -Agnostic Social Network +An agnostic social network following the KISS and UNIX philosophy, the main principles of which are minimalism and rationalism. It implements the ActivityPub protocol and promises maximum compatibility with all existing implementations. ## TODO @@ -21,6 +21,7 @@ Stack: Elixir + Phoenix + PostgreSQL - [ ] Sync user settings on the server - [ ] Restricted direct messages - [ ] Direct messages tab +- [ ] Multiple accounts ### Configuration From f30e9e0091d4bcafa28acbe7c0fbde4f32b1956f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 24 Apr 2025 19:25:19 +0200 Subject: [PATCH 010/144] Update README.md --- README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e8b50b6..b283974 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ Stack: Elixir + Phoenix + PostgreSQL - [ ] Direct messages tab - [ ] Multiple accounts -### Configuration +### Server configuration * Post preview * Character limit per post * Disk space limit per user * Enable/Disable custom emoji for whole instance +* Limit on posts (count/time) +* Limit on storage ### User settings @@ -54,13 +56,20 @@ Stack: Elixir + Phoenix + PostgreSQL * Change password * Token * Enable/Disable email login notifications -* Enable/Disable IP address log +* Sessions -| IP | Datetime | -| ----------------|---------------------| -| 127.0.0.1 | 2025-01-01 00:00:00 | -| 127.127.127.127 | 2025-02-02 00:00:00 | - -* Clear IP address log +| IP | Datetime | Action | +| ----------------|---------------------|--------| +| 127.0.0.1 | 2025-01-01 00:00:00 | revoke | +| 127.127.127.127 | 2025-02-02 00:00:00 | revoke | #### Filters + +* Placeholder with rules + +``` +filter keyword #tag @user@example.com example.com +``` + +* Show replies of all followed users +* Show replies of this followed users From 0463f6a83765b0e14edd5311fc44b7adac0996ec Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 24 Apr 2025 19:27:08 +0200 Subject: [PATCH 011/144] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b283974..6985540 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Stack: Elixir + Phoenix + PostgreSQL * Placeholder with rules ``` -filter keyword #tag @user@example.com example.com +filter keyword #tag user@example.com example.com ``` * Show replies of all followed users From c4783204d994a67045837d0084fb6b5e60a88fe9 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 15 May 2025 16:06:43 +0200 Subject: [PATCH 012/144] Remove CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 132 --------------------------------------------- 1 file changed, 132 deletions(-) delete mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 67fe8ce..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,132 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official email address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations From 09a48be904009e5e4ba3f37e9f3b9fe2cbf8035c Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 15 May 2025 16:07:07 +0200 Subject: [PATCH 013/144] Remove SECURITY.md --- SECURITY.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index f1ce03c..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,3 +0,0 @@ -# Security - -Please send your bug reports via [email](mailto:miraikumiko@disroot.org). From d825201c4f0a4d594ac12e1d1fb6590b29263612 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 15 May 2025 16:20:30 +0200 Subject: [PATCH 014/144] Update CONTRIBUTING.md --- CONTRIBUTING.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 183ac3e..58d0c38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,9 @@ # Contributing -Please send your patches via [email](mailto:miraikumiko@disroot.org). +## Patches via Email + +You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). + +## Your repository + +You fork this repository and make your changes in the feature branch, then I pull it. From 36d47c426bb6ef4c6423f82d1dd10e10c89110df Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 15 May 2025 16:22:29 +0200 Subject: [PATCH 015/144] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58d0c38..feed8c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Patches via Email -You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). +You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). Check out this [guide](https://git-send-email.io). ## Your repository From 9c9ea3abb75b40b332031e2fadbef03a8770e28f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 29 May 2025 14:09:25 +0200 Subject: [PATCH 016/144] Add instance_settings schema --- lib/nulla/instance_settings.ex | 20 +++++++++++++++++++ .../20250527054942_create_instance.exs | 14 +++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 lib/nulla/instance_settings.ex create mode 100644 priv/repo/migrations/20250527054942_create_instance.exs diff --git a/lib/nulla/instance_settings.ex b/lib/nulla/instance_settings.ex new file mode 100644 index 0000000..7ed5956 --- /dev/null +++ b/lib/nulla/instance_settings.ex @@ -0,0 +1,20 @@ +defmodule Nulla.InstanceSettings do + use Ecto.Schema + import Ecto.Changeset + + schema "instance_settings" do + field :name, :string + field :description, :string + field :domain, :string + field :registration, :boolean, default: false + field :max_characters, :integer + field :max_upload_size, :integer + end + + @doc false + def changeset(instance_settings, attrs) do + instance_settings + |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size]) + |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size]) + end +end diff --git a/priv/repo/migrations/20250527054942_create_instance.exs b/priv/repo/migrations/20250527054942_create_instance.exs new file mode 100644 index 0000000..4fe6dc8 --- /dev/null +++ b/priv/repo/migrations/20250527054942_create_instance.exs @@ -0,0 +1,14 @@ +defmodule Nulla.Repo.Migrations.CreateInstanceSettings do + use Ecto.Migration + + def change do + create table(:instance_settings) do + add :name, :string, default: "Nulla", null: false + add :description, :string, default: "Freedom Social Network", null: false + add :domain, :string, default: "localhost", null: false + add :registration, :boolean, default: false, null: false + add :max_characters, :integer, default: 5000, null: false + add :max_upload_size, :integer, default: 50, null: false + end + end +end From 920e03aad6f522af52cc0dbb15c10c0d25cfe50f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 29 May 2025 14:10:47 +0200 Subject: [PATCH 017/144] Add basic pages --- lib/nulla_web/controllers/page_controller.ex | 6 +- .../controllers/page_html/home.html.heex | 234 ++---------------- .../controllers/page_html/profile.html.heex | 68 +++++ lib/nulla_web/router.ex | 1 + priv/static/images/avatar.jpg | Bin 0 -> 37876 bytes priv/static/images/banner.jpg | Bin 0 -> 22671 bytes 6 files changed, 89 insertions(+), 220 deletions(-) create mode 100644 lib/nulla_web/controllers/page_html/profile.html.heex create mode 100644 priv/static/images/avatar.jpg create mode 100644 priv/static/images/banner.jpg diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex index 3daa035..76dd567 100644 --- a/lib/nulla_web/controllers/page_controller.ex +++ b/lib/nulla_web/controllers/page_controller.ex @@ -2,8 +2,10 @@ defmodule NullaWeb.PageController do use NullaWeb, :controller def home(conn, _params) do - # The home page is often custom made, - # so skip the default app layout. render(conn, :home, layout: false) end + + def profile(conn, %{"username" => _username}) do + render(conn, :profile, layout: false) + end end diff --git a/lib/nulla_web/controllers/page_html/home.html.heex b/lib/nulla_web/controllers/page_html/home.html.heex index d72b03c..6bcfbd1 100644 --- a/lib/nulla_web/controllers/page_html/home.html.heex +++ b/lib/nulla_web/controllers/page_html/home.html.heex @@ -1,222 +1,20 @@ <.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v{Application.spec(:phoenix, :vsn)} - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

-
- +
+ +
+
+
+ +
+
+ diff --git a/lib/nulla_web/controllers/page_html/profile.html.heex b/lib/nulla_web/controllers/page_html/profile.html.heex new file mode 100644 index 0000000..9f771d6 --- /dev/null +++ b/lib/nulla_web/controllers/page_html/profile.html.heex @@ -0,0 +1,68 @@ +
+
+ +
+ +
+ +
+
+
+
+
+ + +
+
+ Mirai Kumiko + @miraikumiko@nulla.social +
+

Cryptopunk in the past.

+

Silent girl now and admin of this instance.

+
+

Grew up on hacker culture, philosophy, good old movies and anime. That's why I love cyberpunk — modern philosophy and technolization in one bottle. I also use Linux on a first-name basis and can program.

+
+

Can play shooters, chess and other games where strategy and psychological analysis of opponents are important.

+
+

Bunnies and rabbits are superior!

+
+
+
+
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
+
Catalonia, Spain
+
+
+
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
+
2005/02/25 (20 years old)
+
+
+
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
+
03/20/2025 (2mo ago)
+
+
+
+
+
Website
+
+ miraikumiko.com +
+
+
+ +
+
+
Posts
+
Posts and replies
+
Media
+
+
+
+
+ +
+
+
diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 95706c9..5fb2edb 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -18,6 +18,7 @@ defmodule NullaWeb.Router do pipe_through :browser get "/", PageController, :home + get "/@:username", PageController, :profile end # Other scopes may use custom stacks. diff --git a/priv/static/images/avatar.jpg b/priv/static/images/avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3df25a703e942962d6a42bd5d4d5e39472f40fcb GIT binary patch literal 37876 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≪R?)*F&}`Rtp;C zvn8n4mhKOsGUJf)6qE!cws3a1(K*P^F_1yyQ-!~z8*5PzH7e6-mN+P=DQq}<+i(NJ z!B|k`j@svbx@U*23Sr#(TG6l~xcaVd$U-^#G$)+Lzi1yikFS48dn+j^u~_F!S>P8f z#ITI{4azh``gjT}a=1jb#9XVCrXj{a%AJtnTSy*c)pSO=>04P4s3?P_?Th~av?-5Y zAFIDBQSt-KQk)$p38_ML+4)!duRl$?udXRQ=xq9^`P1W5n^GOqq-LINLrGiQyRWia zq{oDx9l}?(5 z?wb{c<9U{#w8Lb$kiJV_BqQFU1ym(Uc5K;mC9Jyo!rpOe=A|g{C*ErD>USqT1>MCF z5eiNSU@Q2ESLP`_2vdp)Kq^t>0HfEnDBKDa{;ddW+?0X&Qp?lw$7k<~=q;qZ<;Gp#$t>@E&~g<4!2dwA6Q| z93nVUo>mXGvB!b!NH_z$`BPb^I_4iMQ7=CDQoNE0KQlqb^&MhUu7sU{gmUGCfk~9_ zk#u4di483)aZ7jB$KB5n=fay0mQ>*CLV|o~3(&0ACxop27R%;RH# zg&|($1e$zlJF;(f2zLMymAu&NTOaD(2WyD;EGT=aq~OYYD9(kFm{+Tnw1N>Y{UXi> z4CEo<=euIRexz4Eo80R9J-DYMDpC>$#Qs%C74@2p1z+s*S4XGy?a=NH`ArYyS^TP( zqyGSi$C3}o)kT5mB!o?{4j#KspXY9@Eco!Rwx^lvcQ#dk>t_mS#GVRC>{3lv{)QpE zxYiZ0g0+Yas3aaLB(iWmH6_0rSx0=T0we?zow;pEK46;1RSTLma+#fag>)$sdgMt~ z(S)oiAq8puRTlSSu&9p8IWqFazD?FlwUHhZO(O%EmaMC29p@<&jcB_}dr?GPSeQoqm~Qutamj>eE>%T>w;CgtT{WL2e+8= z018vp7beB`sAvwR*vU#lPbm(k7{}^2ljh*=q9}IUturw*y2QyUD~_hq>+b^tX*@i^ z;f{63`a#Z=Ww>qdC0m!55{0~lqqP}FF6{F7)2+Fd#7~nUxeOPaNJc^IPH;2Joko!+ zI4$lmF#~9hrqhFzp+tpX4&rm=nvzv!g)yW&fLrSj6hcp{!~>jmR=)gHTdZQL>YHAj z8o|~^rY1>YmX|ks7Z}qQ6XydMFwS=ZRM`rghp6n){ zk>a9HOBXe^=~Fsx*of#V#?;7?O4d(^X|#_cP%XA{^|wm1uIZkG8s5o6cLz8kJ7`JY zCmqS$r6hggis>Rb^roa+^rgw^4>t7crNgLxQaA}uG(mT&9gH)CG>-LdJ%bRn%6s<- z9c-5iib@dDdjLWb5|9UQ08^)>O-P&l`L{mL>Mf zCKTwb$zjBu%{rW=W4r!*#VpnjDhd_S=8Tom{oSb+hn-!DaaU3o>ju;ZtkS{ffN~Eq z4|p{jz38%z<}n?^&9j|^{;BL92jf?xrX3uON2cy}y@?8xg7j05VnQ-gk3J*GRYRn* zrb>4tjP@SFI1%nN4<3qc5UiT|)~!H{TU}UcKquAOqA*q7fS{5`XarRC({+kWThH{C z946(eOD)88MJYW?pzT76j{t>&2JS-~cp9oq0^_JgeWSEzmm0a<3SWk)=*|*n6h8b! zs@*LPr*K$E0B#4EC@CJ}Kubv3{{TU0dtI=cxgD%LMM)Y=F14J z=}UNbdYqc)f}Y3B4E*Ti9Y1D+C(U@;_ru0sjm=brxn8c+(LSBlQAGNHJVqmp=% zd6VL6UAP=5Z{TPrD^N;(r4R?5ShAtdq;32&Z?@POU#{}KGC&`6Wi5m6+P~@=x18iK3aKcXXESa*%r*SK~z}F+zn34364R z4w)0#RAeZ*wFwCwssk7vXB3@MA3+urUph*&q2v|EjNzE*BZk(7{yqgkBi})6wEck= zjTZ|~oun~u^i`j{wIJ^&%}P}twz%WA*p57%k12$8iSAHH3Lg+?;>whzDMu<%$u-Y_ z!5?YGzMvgb`xR(pGBQB+j4i85Vp97Bp$P=Y}@N~ljX+OnlvVI)f9F*NuUh*fJcDEWCaNs2L(*M=62e(R z6zH^XNM3gFEo7W|RFv+iyS72Z5{&UlZA0g}qW+D;i2jd+GV9A#gsBa*Hz{X(U;*&$ zr=+MiXA+?%(LE{M!5H;O@z_aM#Ccafk=p+NeT{uXDN508)KJydLvhufrAx>=6tT!0 zV~>ArDmSc}T&8BNr8RDk8BRKb%_knP$9hQGJ@nC%CYSvgh>oOkC{tm!d3~U)B#d{1 zMIMhKztWIphS(}lR$`JH4Y&fmMR|qt~?}U^X4nO%dN5mSQfifb$T*Tm8eBR zN>h=L2_y{mlf?XKUfFX~EMY7aHlvhX2wM-j! zptf0Tm!CpQXjIqK`XVnRhS0n)%Dcwn)+lF>R*~CFoe#o^WYJoKRooPTwZy<{b~vRe z>s<*awObO8d8lRg6s3}-5~IaOJkEI6RciC9d1_kJRHYK76U>v$*E$p`j*KUJsZV*U zhxAU{7@1 zQ=Y)dk7kC_v$!@1$id0u`P7I%LWK&d6ev)j6^=gYCW#01ldP#q%3Df;Pa+8;Bi=B5 z)Pb(APr^hyeYw>)GHQt~qztmcFkD#AZxp3GIjDC~EMHjbpsb)}*0AC{#ZeBI>@8Y4 zLBGOny|LVITw~+=wT!FrKf_mVMPA*xGW0u=l5SB5>l6k9&8ygS4$Rkg=8%U6=8mh*K*dNzuMHVQfyYIG@i}r zI95H%SMbyaFqd0yLE>@Mtp2d%edF_~6QLWSw`%)LpjDo!_6{?|Ck`C`?&=G8>Qdut zZN#N$T1r%v078N02t36z=UC+0Kk8?uoTr(laYFkQjJBe{9Q9|*MP@(oz4-}sQ0j|`ubFxIZNfOjujc@~KmmGw+M`BhE zQsR6R4~8iT_HHl;O~N64ul{ZV6to}OYl_f(w^AIo5bI6=q%8^sDhIS0M!%ry7*ed(5NF&{^CiZRoHl@$ z5~T-RKB@)>iSVq+>s?ucTDm2+A4RvTbcC@k(`mCZft7GC^=j(KJ&N2=uio+FQ{QLN z^&Cj0Nmrw|%S5MiT2N=oE8ov2dE zib?%W02O_mz@8Z)9gQD(lJo2_4Q@+k+lrnqW3fRT)SndQsBR~nDX-N~7~rU&e=dI! zMWn%gGSrtEQdEL4Q{v<%?>SdXHF7Sa7Cy~uGbGZHg#`o{=L5EP=dsFT1A zs2>`x^t~HX++t~2b_mi7R_Sk|L+zwsx}>K7DE6nf9r!dBA64|wxw9l%Iy{J(($d?m z3DQQ?5QG&dwAuEE2_zMB`?PDJ9Z#vX+{d9#zcC%xB6>>?8*45pTUW8bIHUqHlgRU^ zPofr!I=fHYRJCEtf}!%{R?!2Gj_g#S(P$b|U&crY48*xdSP4)m5pE3xV?Ch`byO3^ zjIl?BcJ%k3k#V}LxYFlKAx;E^Z3sXBCA9GNPzHN*PF+L?^jh)X)I0bZPSQsqg^}wk ztWv!C#Trq*9r|YSIeL#L=Rp(wA*HQ$=*=sN5cA4TJ22KiI;zYlewCV`%U}cGMOmMu z8t~ggJU7VsB_s2yz{&prO3VYj0MJ{miS|b@pAGenMzjV{6Ts)v6p{h#_I_07)HZvj zr+cdO*V~4Z>oUWohI9JLPp-UoXB3;!1Xh-p3i3!{Nl%xhJ^uhIaO#Vrgcw%$RLd## zTye97D1FqP!QOfIS3as6vMhEh8%8wyqsnzY(-4~;Ji**S^E6893fS$kB%3R*syf^( zzK6ZEuY!}2*~wmesJzC|)N+)h1cRIbTy2a#^tK8zh|jv)YjI%gBX&}A+Ck-xS?m=i!VHu}vD_kO9QCQDz4}L48m>Mi<4zoh0(i{4iU38@t9Pg0wlGsoms(^9gNv2wkO&^OS zCf_}k9CfKd1Ib_WG(dsaYf)Ozt=w$3Kvz6x6{S#e*n;GgpmSfUJ^Vj9^mY`XZ?Vc+ zKo~P5YEYln zQQDE`P_1pT*K^cS;WDBbkBL)OM;)0xTYhU$DnF`|_k~G`<;+uaL|GlP*}YP zn$BX&M6FFnjLMHCx5Z?S4aDcjcT|S1BaJq1Q+*RrTM*-52UJ28_tSzFpETtw9vi!j z19I6V5ViX-N?b(P;5OjL2N-n)Nm@^8LfglI%`v)Tgo#}mYLbJ8V99zy757w$WnY4b z_tclH?b|g@l)pFXn9|HSL~z{8$ssxNcVQp2YCqR|9O&z$Ua)XW*Ac_PX~)ijm1qm=+B%2G?RBzSS^gv%Ia>5-E)Ju!++AoZtC0F9wn)K1kjfk* zJ*R(Jnm=;4Jpido8=35IavpuvECmHeoh|A&Atu|W;Z95m7ZnF)OfGePix4XW?;gQg zvXFRi2fm~rq@*cMDJVigQkJd(DI7^Y00Mp07RP|8tx4C+Nn|eQrKiyDZlMvRzzW*i zCw~O1YBz;v+B`-o1M6{GeHx6k${H=c)Tl9(K}we?0HGuGdepe1*??)i03yXE+^BAe zm((@s&z79t5rl#Rr8x-?Agu)?@g)ZyrfGK3qqNoV!Mnv($yW5TxQ)UxIX+2vQA!*owKf|VE-Wo230l>JrOa_SsVA#$ z#B21Ny)<snyUWy)t!tiGwCg2u{5jJ@2fgmi`6J-@97) zk!3nEvq!M#Sa4#c3QL>TwGaq+5>vQ?z=Z_o9m=TBr0$5WYc0;u?>OTOlHzS;!WSJ~ zzK)~ze^Vfi%+%u^IwD49Ghf-qZ7iqBAS8YjxEZKgsIoPtqqUI-TG->=I-&wi-YVOY zjBw!kcCX@9Sf6~g>UNaG4J$p#P))-e* zf`+i#NzT*&kbV_p6?;r{2YtLpFWsC)g(W{$+=Q1LS@A&Pk>+#ZL~lI-YX~kU&{~Id zX-e7`8Sn`4QdR*2$BkO>IM+GR7cv`>+IYu#W9r@nj;(ysuMwPNc8rRviUB?w@FvKM zEtRtS4i?;PV+AS)1R5_^i=>W@S=Y{{@ofl&ew_$B8ax|$=2g|Y+Pjs-I9KZN=bEgO zBRdjHj7V*5yxNKug^U!a91ur%G)D>)C{Rru0L5lPHkiCmjp=&30H0`xbGmPZZ))JtCKk3NsLL2bQUT_Ej@BF$0k+T6B+v-_?n z`>Js1S`$$*U%hE`i> zkG}r^;DJ?&G0o;)5BkK4vOh>Sa?Dg8XsPvl(N(V2cU&Z%_Xp!abhI}1ZHrTC*r0-t zdvafU!6>o@rw~$~mi<;YKFgpSx-^YK*3R9x9rdaSr%W#4UxiSQ)$)pWQ>Oqr4% zLuqX;x~BpY$sm$&JgZU>z-31RiL5ClCkg}~8VFL5prn!%IT1m{sl^pIr71_WoSLe} zb3j%rBb@`6qvk`}uFQoDh1?@2AMsOHdrrYO?NsEKINNN_kbp-AW{`Iu5Ykn*_SCPt zRNta&CCc@ytkG6etBGYCA%1_SLW+TxkeUdOVa2sfSbStaszI zc7jK3E6_ZSXc_h^;;%7oHpxu28&N?{$xzC&kfZ&Zi8$ig$jCjMj}cJztC2c?M7JPg z25$HH#g#=0&c_~>K}JtJB?M={)Th#2t=7yPMQbg_xm%Tm%-TsrbHzzoN4C~*R5^i_ zCyIE+SBsCPYWNnV#Z-3fZ$EgH!658go4SN#a6DG(!5qLz?VzXisfgam0A>E{7u&Kg46e`5Hq@}efN(o3A zDkp#gwt^+;gvO4j76`9(2JjtJt%ufT8%Zm9N$xAaY<|(EU4}#$ns(aU2r(YHrXyW+ zmt*d3GKxXl>kO?x9tlnoJ4oe8mwTM8L8|q}qi~fliyKWqK^z-t*A=jFI<_;X6Q5U^6VRqZh1P8QO;l#|2Yduc%M94Vh}ekgs-MPriGyKID~(~NZmpdOl5$KndHj(Nv zdPds>*u&yeePA9syIUrG%Yi1L`#uvsZYwFW08Dc~N*sORqhLGe$GOqz|+7U4PN zJftO1;K@5j1oabuQ9dOu0p&}Yy2Bq*Y5S#;UIQ~)Q&~S@dCC>;P)VTk@`W0>!+PH} z_>!9~$d2NgbqAVMl0hF4MQ9{mgOZoEHO?}DUtMRM64*Req_kW}{v-;seKT&k*!5dz zLK6Bk{5!tjKHE>NW8f06jaD9kR+RLsr;xr0kn53i++AsHgZ#p*y^yZ$eSQRzvWK)B zbUqjD6>o70C*rCHe?qf;D#vkbEU6K$k`j!J9<9Qe2=`&?bj?XRdTK{99RC0rsFUd5 zJ!z1*l2ftC)_79}PDyc{(VWlgVJ#EpDq>Czq5a&ptNdoAO0GXhW$lp_9nHV~GzzZP za8)_%b1o?4oHm*Fy2>Oce%+KAzt@c#gqu3b^q6m&pIb|GmIlnLun z0#n0@B%Ue22RY?~O?!%6){!B*CDJt8lcR>(SRq#w^`qBbc!qxJd9Q+x3<^p^&T9`z zLt)S8yj68l-N^~LJQc;1asI_5dg}Xl+Bjz}ONw1?MXf84TrS)YvJ{X#R3S?V7~}@e zojHh6)^elSZE|jti1ZlEmz@bv-r@T#qa1JbEb7PU}5u zY_m2>TYb#Fw2$oBPZg`-z<7~CXFiGgAM`$$UZtMU6E0~8kCDX@!R9}0Lq0%Nfk?pR zMBv9}BxYkpY&RX0C8p2^1f?SY4{#K4a-sza6eyuWg$e;eg$e;eg$fQ%ww0u%#D%3m zf)p|cJi)5b(jAS>6Mxk&^?<>hYE-B$tz26zHm16qo@G5_dw*9Q#;oN=KS)g%{W0kB zVq-VH=Wfi{m>g83k69nQoR2UlC<4aSl%8XSVLa=!q-}Xba#yS~+-r!SvmF3VO|svm zOUhYg$>H{uw{lSGmbi|iz!bR3v&%T|s|V6_m8(n(Tz6E|o6GInkm7h0rFkx^+|pe4 z;-P<{gc$d|7=rjoa?y5iVG0=wLyaLo9_%f&s2=pxqtaie#J%b3!;G}M1-)*Gu62id zDtSFpUE7ak8RB!vM~4as<@mwI_19L~dbh6DyK)ebji}nt2?M|@9_o8*=zVULn|VM{ zRi1lO!0$p-L!)9OM50caM0fE(NitH1UBgA#KWO zX?%Buf)(;Lo9R481shx~poL)JBzBI^j|#7SJIi6uKtp9JQr&#AuI5s$n}_YV969bn zYF}o2yKa+QbSNtI1!l~EPZdf^x|YwpY+sgYzI4$gqpqdf;he*@G}XDE5Ylj??@>|r z)_I=0f2VseozY|@TslV6FQKfr0+ z>f3S07ShtRDM}$pAdY8|6xG$zfz!H)Avo${NNAk%%wnwk$HI^3^!0jbD`$$5w}Hoh zTDl6o^qaCK*V0SV8crL!u$hO32U(JZ{L`O(M0I99mNxL^i%#5vPUd7i7^}fa3j_I8 zRiN`U4jzu?rr)k~^tG#E7`VoDO3p9?sVC)Dr=rEZC#4N~ho3SThiddj)!dG`(g)*J z9>EQB?g)z6!rV&spAeFvk9ZYl{*Si?-LPx>;}|}TXNeznF}ZEUT9!_GO7ofpE`gm= zXX%GrbQQkThPVzJUW~)ZPoCx;C&s5W`nE^Uau4{;KwStn1KKHSBz1A3IFLCjaxM*@ z%BL%<<()e*S>g`g=fSB`t1r?u;rd-5oPArzAMwBCP_~piu59DBLx1BHDt#oM{{T;* zB=TwO{{Y_~@~E#wWy|a~$2C6C{{X~IRb1Fb4aUld9P#l%Rs(KV@qr&}-XdE_?AJ*U=RT2R`2RB$ucGJ7b_o3@s$ zo9C$#CfpzECcE}leT1Po`v^$dpA4R4*FK^#X+trzR2Y#G8exU?2yG{V5|X?me5nrKK6?`B_fmQy%}afee}eRQ?xmTU%HpZA9AB%{Kw(7;rCrGzRG5>~GjQ3o#*6mnis{;VPo28Y@5q~#ovO>Qd`C{U^e3KS?d zNF?A4nz#P|L%k%Jb_k?AD?arTK;r`@wx9+FI%(@IUTg_%n2=qcInlhf*d#L6a9wi%ow?uu z1gL|KMx;Zsg(TQD1-|yq=n~m|NdtIBK}qf@Pzq0x8KrM3suUvo zd}#wwYN%Fp>?vHCDptfW&I**X#3#)BhDpHVO_nBJWf$L{IZ1udkjz81xXA;ORn14e zXMyYha|sv_M*~4zys4F;uJWIDR5=$GuC20^?U0Z<#F9uTaHG^DV>uW-HC~LWfspcKPDZ#Ju)=a9ETcHZO1|v z{?G)h9{d71ky7GQ>Wom?Sy8~oa634kQcGn#3LkB?GCVkf z73Kv~tBj{6&yy+QOOLpxR7uIoi9elLewr>T{(;Zb$yV7{xz04+;he;E9aM(`= z@v6Y1l^Q_%=v|aa*0E!)JG#P?%RCUYxPC~-zO3X_ls z%Vm3y_i8;_zD|6rhMfVcdg2ZO8}7v!9f?`n{G~MK=?d1F)>oUzNOcNep6$Tk`;QuL z=-BJJO1X8Ran}10ZVjbFlALd2fd2bg_t1BtMkd0360sp0MtCT32g-{IsM$0W=Eqn; zAKDcG<`bF-u2OhNx19d~t|Q`wR8mH*N=}y6SoVZ9uj3mZk)=xtklVLtC?hdu5OaUYnfj(M8CI_B8T+fF|QP)e9})&lY}0P9i-_XQfG`i9c|j`u#-F(oo# zzP1)TQb^CsG#hD`gndJ6bzD;1rj$l;k-TQCUq*piW9a$Pbb^qVOt+yUC%0-D1Kd_> zv9xx$k~;`BIclvymO34gVUNY@zNVP@!oI_082GJ8_!V7a+zx<;D80oGg&4202t5Boxskn6v)6^7H^Ld`DfZ=?x!&ncTUd5lhR1-9LF)GRl>3KgEp1+5+%{*b^^`07N4 zy3O3S^@+}WF`D$pKCvD(<=i)VrwPdbjAV9r)$ZtTq{ek#kclEe-7+du9%@tVHsBZv z$7)svLGQ@ys3o9sI8!HB*yS6Dyj!DsTfvbX=)u5+IM`8IN(te)B&&hOLXQ!Q(}}C) zy+hWJ5OMZ_6nUu;q$DY)TX1)!K+zEbE;flSg}Eu~E#)~&YM7r#d85bxrFx4898C0ml<%G-wS#B<=FgOmafA^;%OM81QZ;VP3l{fnZ|8;f#pwWUZh)! zfJ)xL&c`vpItF|e21guHK4aT8F7e!&${iRDudX>l zTL*DS2Y>|QJPvqgkiZnOHzILUmX<$D%~nFED3;#|gof1x4g!E&4)1BzPr?pK0M1EPF+#n`s%J1DopP89T{B_m=%{>`L)J0p58y z0)S3W06F+m8JnD#@mh}^!rN`6B{}t#f!?KLlj3-qFD_ct)3015E2wQH#}@mTLunjJ zk6-|CppU+pH)vnvZsTQ)`zO>= zK~LC75d{op4$$(TL_2@!D1Usot;Mr(ZEqwzre%+UC~@DkZy2M<=>7k=DDG zTK9y9mGGd1K9Ol{*cN*XG1bM175j)!b8VnB(tbv(TDTKa_e@fQx~p2amk;#|99U>h zI0Ea<6%XcBK@P=v5zzYEA*GU(s~icyKY?TW}fO8&1k`J9+0Q1Lj3rZ7Xk0zfs#Q z=@|VLrk?UxCO{?1L#ONc$;I~oLd9;%Y{`%8}xTo9jM`ZfUZA#ZT` z(yoWMD$S%U_SKxZ8Y5_NS?1<9EdKx!jaY?uFtddKM{)DbRga|&kI*%|d#i2jX6qR% zQ5=iS`iuADVfWRiKG78(^y8$?tkGen3KKF6fcr{UEGTVKbJ?7v`=}|Zco9zx8*iPg zHFQgSXUY}1=_o7WmiDDisN=+>d}*g8r84^4S_&3|Qly?>jszNU^nHDQPF`1ZhT>5r zhDdCvWMq&CBex?Wg4XE$NY<*pz|>=^LlUlxFcqE&OEC(Sj}EbJd?@Cszs8k&ync_6 z+=h`VGi7b7aNL&0;0WMxRdYw{>y45kA~vY5$7Vw0w9CzINLtgiM}`kBLX6t_LzQWj zJFSk)sPg2hSxEZ5jw_i?J2(ypG!e}KQtaI>^RK$3SZ(pr6@tp%m$?LU1e8}hewm0J z{{To?JzNq@S@;iur>~k-=7f=F{bWq zM?4UxF0q;_@km=>k>iR+={ruv(fWCB&4WFOva;N4+@n4MyHup1z1v4Fe9Z(Dtb#!p zHLYx;lUmRU6ev&%6ev&%6ev(;oH{2~-E@`dyM>-dROa1|AwzN=MsiZL_K(A|o3)hl zpt(8|rENW{(#(Fj7R$263@Ho3RIcWnJQd_{uR3vD-@M!8x_t^1n0W*#4LX;ZPj2)i zlDrPok1?yS0f-Td(ML@oE@DdzG0C57bfihw7hk z6c%dLcM=#5+b%`e{{VTHw*lrkr1%gG8-82wfFF5J1oA4V8>Ft0ZIT+dT=W&yOk7@d zLTb?;tAA)n*c{xF3C?>?M};6-`e4;imJ=#XzTlEEvSSHCzlj@8eDg$DS>?ytTx8o9 zDGL}^TH;!3p97FevJMZboP)}UN4d8EGZObMA#@GCmfF6lYVKIcEw8_R0mebk3S-*( zans6gW#$da-7pR&AHm8kku(zaf)wx&6Dl#T_c19Cy}aN$1;(ngx;DmJQ?;}VRkOmv(G z@zj;Az)m-*%x=bb6rf~e9OF2zEY#6r};qy^ZTrJIDo1 zvhZ{_;ka`>=a+Rsa=LZXjW;QzY3bJ`$VXQEna{QrAtZ50NJ$Ataua|!Q$>!N-?Kwh zJt1O)X=OkHw^pf65;+o8jF310atC1IYGAcRO}5(HTF{2j3Y4{|1f&jRlfVxetQ|V^ zul*n{Saj3J^dcAa@<#(PK=K`-vDiC#p32WimknaBj3UU?m$`3=bAWO#)FB}XY^^C- zNCcdN>Yf9ZGfMY`3l*p`F!R|uha zAKyn;9Qm~8@>NLaO*?hYkd_8YF7cVig2RLV04-c@lvxdFwywB2v`Iw8gpMViExbSO z)}!vAc{HHoK}bHTfFsD%LuPyQq|4p$D%>t|)TRzU?CpJ1UmdDf@6(d$eN+yNrpRU8 z(;8&LWTf#gEQZ_1j^!ueO&j#)bZ(VUiUOaB7&8%|GreiUZRf*w3HQ{u8s=}*6Vd}N zNdEw(N$N@2>Cjq1>?jT`Ir7Q?rlzuO3Fy~INNf+TdPpa?)oMC_5Nc-BdQI4RS6SJe z3wBEM<-T%MM-{21Io2ON;Gf>8KKP>l07QCSK;$uVp3&pWC}Bd3mp+b+`gXTntP%c@ zLrg4g##>B=opaCBXuZIPxzDYrClzROo?%l0L01C2{`tWm32V; z5nD2aExRMD;H|5UvIlxG_@Gfo#2~1C6nt`~V^Z9ar!>UHI9+wQ ztTyX}aZS;y<2^QD3U6r_#-6G-})XOy$L#`#KjKt)4l3Vt-J}

|$eCQH^J(LM2 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 literal 0 HcmV?d00001 diff --git a/priv/static/images/banner.jpg b/priv/static/images/banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..df9b751907c61feba52a43074a8ecb5b186b7ca6 GIT binary patch literal 22671 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 literal 0 HcmV?d00001 From 9d2137d30ff9a468b422f027f217702d371b34a0 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 30 May 2025 09:47:25 +0200 Subject: [PATCH 018/144] Update instance_settings.ex --- lib/nulla/instance_settings.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/nulla/instance_settings.ex b/lib/nulla/instance_settings.ex index 7ed5956..d2dfe9d 100644 --- a/lib/nulla/instance_settings.ex +++ b/lib/nulla/instance_settings.ex @@ -3,12 +3,12 @@ defmodule Nulla.InstanceSettings do import Ecto.Changeset schema "instance_settings" do - field :name, :string - field :description, :string - field :domain, :string + field :name, :string, default: "Nulla" + field :description, :string, default: "Freedom Social Network" + field :domain, :string, default: "localhost" field :registration, :boolean, default: false - field :max_characters, :integer - field :max_upload_size, :integer + field :max_characters, :integer, default: 5000 + field :max_upload_size, :integer, default: 50 end @doc false From f61444cffaeaba3e2f57bc42b23a9ea778dc285e Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 30 May 2025 14:14:54 +0200 Subject: [PATCH 019/144] Add users schema --- lib/nulla/user.ex | 35 +++++++++++++++++++ .../20250530110822_create_users.exs | 29 +++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 lib/nulla/user.ex create mode 100644 priv/repo/migrations/20250530110822_create_users.exs diff --git a/lib/nulla/user.ex b/lib/nulla/user.ex new file mode 100644 index 0000000..a8abb7e --- /dev/null +++ b/lib/nulla/user.ex @@ -0,0 +1,35 @@ +defmodule Nulla.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :name, :string + field :email, :string + field :password, :string + field :is_moderator, :boolean, default: false + + field :realname, :string + field :bio, :string + field :location, :string + field :birthday, :date + field :fields, :map + field :follow_approval, :boolean, default: false + field :is_bot, :boolean, default: false + field :is_discoverable, :boolean, default: true + field :is_indexable, :boolean, default: true + field :is_memorial, :boolean, default: false + field :private_key, :string + field :public_key, :string + field :avater, :string + field :banner, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(user, attrs) do + user + |> cast(attrs, [:name, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) + |> validate_required([:name, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) + end +end diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs new file mode 100644 index 0000000..48fa01a --- /dev/null +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -0,0 +1,29 @@ +defmodule Nulla.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :username, :string, null: false, unique: true + add :email, :string + add :password, :string + add :is_moderator, :boolean, default: false, null: false + + add :realname, :string + add :bio, :string + add :location, :string + add :birthday, :date + add :fields, :map + add :follow_approval, :boolean, default: false, null: false + add :is_bot, :boolean, default: false, null: false + add :is_discoverable, :boolean, default: true, null: false + add :is_indexable, :boolean, default: true, null: false + add :is_memorial, :boolean, default: false, null: false + add :private_key, :string, null: false + add :public_key, :string, null: false + add :avater, :string + add :banner, :string + + timestamps(type: :utc_datetime) + end + end +end From 178c2bf72e0843fe493a0c687f1d19310ef57856 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 30 May 2025 14:16:51 +0200 Subject: [PATCH 020/144] Add UserController --- config/config.exs | 5 ++ lib/nulla_web/controllers/user_controller.ex | 64 +++++++++++++++++++ lib/nulla_web/controllers/user_html.ex | 10 +++ .../show.html.heex} | 0 lib/nulla_web/router.ex | 4 +- 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 lib/nulla_web/controllers/user_controller.ex create mode 100644 lib/nulla_web/controllers/user_html.ex rename lib/nulla_web/controllers/{page_html/profile.html.heex => user_html/show.html.heex} (100%) diff --git a/config/config.exs b/config/config.exs index 52e443a..2c52b8a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -61,6 +61,11 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :mime, :types, %{ + "application/activity+json" => ["activity+json"], + "application/ld+json" => ["ld+json"] +} + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex new file mode 100644 index 0000000..b152141 --- /dev/null +++ b/lib/nulla_web/controllers/user_controller.ex @@ -0,0 +1,64 @@ +defmodule NullaWeb.UserController do + use NullaWeb, :controller + + def show(conn, %{"username" => username}) do + accept = get_req_header(conn, "accept") |> List.first() || "" + + if String.contains?(accept, "application/activity+json") or String.contains?(accept, "application/ld+json") do + conn + |> put_resp_content_type("application/activity+json") + |> json(%{ + id: "https://localhost/@#{username}", + type: "Person", + following: "https://localhost/@#{username}/following", + followers: "https://localhost/@#{username}/followers", + inbox: "https://localhost/@#{username}/inbox", + outbox: "https://localhost/@#{username}/outbox", + featured: "https://localhost/@#{username}/collections/featured", + preferredUsername: "miraikumiko", + name: "Mirai Kumiko", + summary: "Lol Kek Cheburek", + url: "https://localhost/@#{username}", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: "2025-05-05T00:00:00Z", + memorial: false, + publicKey: %{ + id: "https://localhost/@#{username}#main-key", + owner: "https://localhost/@#{username}", + publicKeyPem: "public key" + }, + tag: [ + %{ + type: "Hashtag", + href: "https://localhost/tags/linux", + name: "#linux" + } + ], + attachment: [ + %{ + type: "PropertyValue", + name: "Website", + value: "https://miraikumiko.com" + } + ], + endpoints: %{ + sharedInbox: "https://localhost/inbox" + }, + icon: %{ + type: "Image", + mediaType: "image/jpeg", + url: "url" + }, + image: %{ + type: "Image", + mediaType: "image/jpeg", + url: "url" + } + }) + else + render(conn, :show, username: username, layout: false) + end + end +end diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/controllers/user_html.ex new file mode 100644 index 0000000..3c0c6ba --- /dev/null +++ b/lib/nulla_web/controllers/user_html.ex @@ -0,0 +1,10 @@ +defmodule NullaWeb.UserHTML do + @moduledoc """ + This module contains pages rendered by UserController. + + See the `user_html` directory for all templates available. + """ + use NullaWeb, :html + + embed_templates "user_html/*" +end diff --git a/lib/nulla_web/controllers/page_html/profile.html.heex b/lib/nulla_web/controllers/user_html/show.html.heex similarity index 100% rename from lib/nulla_web/controllers/page_html/profile.html.heex rename to lib/nulla_web/controllers/user_html/show.html.heex diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 5fb2edb..46a699c 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -2,7 +2,7 @@ defmodule NullaWeb.Router do use NullaWeb, :router pipeline :browser do - plug :accepts, ["html"] + plug :accepts, ["html", "activity+json", "ld+json"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} @@ -18,7 +18,7 @@ defmodule NullaWeb.Router do pipe_through :browser get "/", PageController, :home - get "/@:username", PageController, :profile + get "/@:username", UserController, :show end # Other scopes may use custom stacks. From 022c07bdf51b079286ef840e04000b701a05f6df Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 31 May 2025 11:24:09 +0200 Subject: [PATCH 021/144] Fix user.ex --- lib/nulla/user.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/nulla/user.ex b/lib/nulla/user.ex index a8abb7e..ab732d6 100644 --- a/lib/nulla/user.ex +++ b/lib/nulla/user.ex @@ -3,7 +3,7 @@ defmodule Nulla.User do import Ecto.Changeset schema "users" do - field :name, :string + field :username, :string field :email, :string field :password, :string field :is_moderator, :boolean, default: false @@ -29,7 +29,7 @@ defmodule Nulla.User do @doc false def changeset(user, attrs) do user - |> cast(attrs, [:name, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) - |> validate_required([:name, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) + |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) + |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) end end From 6849e8a33006a9df6acf05a07a9cd7f2a33aef52 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 1 Jun 2025 08:55:15 +0200 Subject: [PATCH 022/144] Remove CHANGELOG.md --- CHANGELOG.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e69de29..0000000 From 956f2625fdc2925593a1628429d05ffe01659511 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:27:03 +0200 Subject: [PATCH 023/144] Install timex --- mix.exs | 1 + mix.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/mix.exs b/mix.exs index 09ceeec..da0bd5b 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,7 @@ defmodule Nulla.MixProject do {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, + {:timex, "~> 3.7"}, {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.1", diff --git a/mix.lock b/mix.lock index 626d6e4..c107605 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,8 @@ %{ "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, @@ -12,13 +14,18 @@ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -30,12 +37,16 @@ "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.18.4", "5f5f325cfbc68d454f1606421f2dd02d1b20fd03e10905e9728b26662ae01f1d", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8b45e6f9109bdf89f3d83f810e0cc97c1c971925e72fc4f47da42959d8487ee"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, + "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, } From 162aa095d3cf0d2d90971d3f2abf9df8103be9b0 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:30:35 +0200 Subject: [PATCH 024/144] Add users --- lib/nulla/users.ex | 106 ++++++++++++++++++ lib/nulla/{ => users}/user.ex | 9 +- lib/nulla_web/controllers/user_controller.ex | 63 ++--------- .../controllers/user_controller.ex.bak | 62 ++++++++++ lib/nulla_web/controllers/user_html.ex | 41 ++++++- .../controllers/user_html/edit.html.heex | 8 ++ .../controllers/user_html/index.html.heex | 23 ++++ .../controllers/user_html/new.html.heex | 8 ++ .../controllers/user_html/show.html.heex | 80 +++++++------ .../controllers/user_html/show.html.heex.bak | 15 +++ .../controllers/user_html/user_form.html.heex | 9 ++ lib/nulla_web/router.ex | 1 + test/nulla/users_test.exs | 59 ++++++++++ .../controllers/user_controller_test.exs | 84 ++++++++++++++ test/support/fixtures/users_fixtures.ex | 20 ++++ 15 files changed, 489 insertions(+), 99 deletions(-) create mode 100644 lib/nulla/users.ex rename lib/nulla/{ => users}/user.ex (86%) create mode 100644 lib/nulla_web/controllers/user_controller.ex.bak create mode 100644 lib/nulla_web/controllers/user_html/edit.html.heex create mode 100644 lib/nulla_web/controllers/user_html/index.html.heex create mode 100644 lib/nulla_web/controllers/user_html/new.html.heex create mode 100644 lib/nulla_web/controllers/user_html/show.html.heex.bak create mode 100644 lib/nulla_web/controllers/user_html/user_form.html.heex create mode 100644 test/nulla/users_test.exs create mode 100644 test/nulla_web/controllers/user_controller_test.exs create mode 100644 test/support/fixtures/users_fixtures.ex diff --git a/lib/nulla/users.ex b/lib/nulla/users.ex new file mode 100644 index 0000000..292db75 --- /dev/null +++ b/lib/nulla/users.ex @@ -0,0 +1,106 @@ +defmodule Nulla.Users do + @moduledoc """ + The Users context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Users.User + + @doc """ + Returns the list of users. + + ## Examples + + iex> list_users() + [%User{}, ...] + + """ + def list_users do + Repo.all(User) + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + def get_user_by_username!(username), do: Repo.get_by!(User, username: username) + + @doc """ + Creates a user. + + ## Examples + + iex> create_user(%{field: value}) + {:ok, %User{}} + + iex> create_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_user(attrs \\ %{}) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a user. + + ## Examples + + iex> update_user(user, %{field: new_value}) + {:ok, %User{}} + + iex> update_user(user, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_user(%User{} = user, attrs) do + user + |> User.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a user. + + ## Examples + + iex> delete_user(user) + {:ok, %User{}} + + iex> delete_user(user) + {:error, %Ecto.Changeset{}} + + """ + def delete_user(%User{} = user) do + Repo.delete(user) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user(%User{} = user, attrs \\ %{}) do + User.changeset(user, attrs) + end +end diff --git a/lib/nulla/user.ex b/lib/nulla/users/user.ex similarity index 86% rename from lib/nulla/user.ex rename to lib/nulla/users/user.ex index ab732d6..b03f068 100644 --- a/lib/nulla/user.ex +++ b/lib/nulla/users/user.ex @@ -1,4 +1,4 @@ -defmodule Nulla.User do +defmodule Nulla.Users.User do use Ecto.Schema import Ecto.Changeset @@ -13,6 +13,7 @@ defmodule Nulla.User do field :location, :string field :birthday, :date field :fields, :map + field :tags, {:array, :string} field :follow_approval, :boolean, default: false field :is_bot, :boolean, default: false field :is_discoverable, :boolean, default: true @@ -20,7 +21,7 @@ defmodule Nulla.User do field :is_memorial, :boolean, default: false field :private_key, :string field :public_key, :string - field :avater, :string + field :avatar, :string field :banner, :string timestamps(type: :utc_datetime) @@ -29,7 +30,7 @@ defmodule Nulla.User do @doc false def changeset(user, attrs) do user - |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) - |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avater, :banner]) + |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) + |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) end end diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index b152141..76e8b80 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -1,64 +1,21 @@ defmodule NullaWeb.UserController do use NullaWeb, :controller + alias Nulla.Users + alias Nulla.InstanceSettings + alias Nulla.ActivityPub def show(conn, %{"username" => username}) do - accept = get_req_header(conn, "accept") |> List.first() || "" + accept = List.first(get_req_header(conn, "accept")) + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = Users.get_user_by_username!(username) - if String.contains?(accept, "application/activity+json") or String.contains?(accept, "application/ld+json") do + if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") - |> json(%{ - id: "https://localhost/@#{username}", - type: "Person", - following: "https://localhost/@#{username}/following", - followers: "https://localhost/@#{username}/followers", - inbox: "https://localhost/@#{username}/inbox", - outbox: "https://localhost/@#{username}/outbox", - featured: "https://localhost/@#{username}/collections/featured", - preferredUsername: "miraikumiko", - name: "Mirai Kumiko", - summary: "Lol Kek Cheburek", - url: "https://localhost/@#{username}", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: "2025-05-05T00:00:00Z", - memorial: false, - publicKey: %{ - id: "https://localhost/@#{username}#main-key", - owner: "https://localhost/@#{username}", - publicKeyPem: "public key" - }, - tag: [ - %{ - type: "Hashtag", - href: "https://localhost/tags/linux", - name: "#linux" - } - ], - attachment: [ - %{ - type: "PropertyValue", - name: "Website", - value: "https://miraikumiko.com" - } - ], - endpoints: %{ - sharedInbox: "https://localhost/inbox" - }, - icon: %{ - type: "Image", - mediaType: "image/jpeg", - url: "url" - }, - image: %{ - type: "Image", - mediaType: "image/jpeg", - url: "url" - } - }) + |> json(ActivityPub.ap_user(domain, user)) else - render(conn, :show, username: username, layout: false) + render(conn, :show, user: user, domain: domain, layout: false) end end end diff --git a/lib/nulla_web/controllers/user_controller.ex.bak b/lib/nulla_web/controllers/user_controller.ex.bak new file mode 100644 index 0000000..093ba20 --- /dev/null +++ b/lib/nulla_web/controllers/user_controller.ex.bak @@ -0,0 +1,62 @@ +defmodule NullaWeb.UserController do + use NullaWeb, :controller + + alias Nulla.Users + alias Nulla.Users.User + + def index(conn, _params) do + users = Users.list_users() + render(conn, :index, users: users) + end + + def new(conn, _params) do + changeset = Users.change_user(%User{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + case Users.create_user(user_params) do + {:ok, user} -> + conn + |> put_flash(:info, "User created successfully.") + |> redirect(to: ~p"/users/#{user}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + user = Users.get_user!(id) + render(conn, :show, user: user) + end + + def edit(conn, %{"id" => id}) do + user = Users.get_user!(id) + changeset = Users.change_user(user) + render(conn, :edit, user: user, changeset: changeset) + end + + def update(conn, %{"id" => id, "user" => user_params}) do + user = Users.get_user!(id) + + case Users.update_user(user, user_params) do + {:ok, user} -> + conn + |> put_flash(:info, "User updated successfully.") + |> redirect(to: ~p"/users/#{user}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, user: user, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + user = Users.get_user!(id) + {:ok, _user} = Users.delete_user(user) + + conn + |> put_flash(:info, "User deleted successfully.") + |> redirect(to: ~p"/users") + end +end diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/controllers/user_html.ex index 3c0c6ba..db0df5b 100644 --- a/lib/nulla_web/controllers/user_html.ex +++ b/lib/nulla_web/controllers/user_html.ex @@ -1,10 +1,41 @@ defmodule NullaWeb.UserHTML do - @moduledoc """ - This module contains pages rendered by UserController. - - See the `user_html` directory for all templates available. - """ use NullaWeb, :html embed_templates "user_html/*" + + @doc """ + Renders a user form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def user_form(assigns) + + def format_birthdate(date) do + formatted = Date.to_string(date) |> String.replace("-", "/") + age = Timex.diff(Timex.today(), date, :years) + "#{formatted} (#{age} years old)" + end + + def format_registration_date(date) do + now = Timex.now() + formatted = Date.to_string(date) |> String.replace("-", "/") + + diff = Timex.diff(now, date, :days) + + relative = + cond do + diff == 0 -> "today" + diff == 1 -> "1 day ago" + diff < 30 -> "#{diff} days ago" + diff < 365 -> + months = Timex.diff(now, date, :months) + if months == 1, do: "1 month ago", else: "#{months} months ago" + true -> + years = Timex.diff(now, date, :years) + if years == 1, do: "1 year ago", else: "#{years} years ago" + end + + "#{formatted} (#{relative})" + end end diff --git a/lib/nulla_web/controllers/user_html/edit.html.heex b/lib/nulla_web/controllers/user_html/edit.html.heex new file mode 100644 index 0000000..2f8aa66 --- /dev/null +++ b/lib/nulla_web/controllers/user_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit User {@user.id} + <:subtitle>Use this form to manage user records in your database. + + +<.user_form changeset={@changeset} action={~p"/users/#{@user}"} /> + +<.back navigate={~p"/users"}>Back to users diff --git a/lib/nulla_web/controllers/user_html/index.html.heex b/lib/nulla_web/controllers/user_html/index.html.heex new file mode 100644 index 0000000..9eca5b7 --- /dev/null +++ b/lib/nulla_web/controllers/user_html/index.html.heex @@ -0,0 +1,23 @@ +<.header> + Listing Users + <:actions> + <.link href={~p"/users/new"}> + <.button>New User + + + + +<.table id="users" rows={@users} row_click={&JS.navigate(~p"/users/#{&1}")}> + <:col :let={user} label="Username">{user.username} + <:action :let={user}> +

+ <.link navigate={~p"/users/#{user}"}>Show +
+ <.link navigate={~p"/users/#{user}/edit"}>Edit + + <:action :let={user}> + <.link href={~p"/users/#{user}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/nulla_web/controllers/user_html/new.html.heex b/lib/nulla_web/controllers/user_html/new.html.heex new file mode 100644 index 0000000..9248fb0 --- /dev/null +++ b/lib/nulla_web/controllers/user_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New User + <:subtitle>Use this form to manage user records in your database. + + +<.user_form changeset={@changeset} action={~p"/users"} /> + +<.back navigate={~p"/users"}>Back to users diff --git a/lib/nulla_web/controllers/user_html/show.html.heex b/lib/nulla_web/controllers/user_html/show.html.heex index 9f771d6..708842f 100644 --- a/lib/nulla_web/controllers/user_html/show.html.heex +++ b/lib/nulla_web/controllers/user_html/show.html.heex @@ -14,50 +14,56 @@
- Mirai Kumiko - @miraikumiko@nulla.social + {@user.realname} + @{@user.username}@{@domain}
-

Cryptopunk in the past.

-

Silent girl now and admin of this instance.

-
-

Grew up on hacker culture, philosophy, good old movies and anime. That's why I love cyberpunk — modern philosophy and technolization in one bottle. I also use Linux on a first-name basis and can program.

-
-

Can play shooters, chess and other games where strategy and psychological analysis of opponents are important.

-
-

Bunnies and rabbits are superior!

+

{@user.bio}

-
-
-
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
-
Catalonia, Spain
-
-
-
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
-
2005/02/25 (20 years old)
-
-
-
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
-
03/20/2025 (2mo ago)
-
-
-
-
-
Website
-
- miraikumiko.com -
-
+
+ <%= if @user.location do %> +
+ <.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" /> +
+
<%= @user.location %>
+ <% end %> + + <%= if @user.birthday do %> +
+ <.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" /> +
+
<%= format_birthdate(@user.birthday) %>
+ <% end %> + +
+ <.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" /> +
+
<%= format_registration_date(@user.inserted_at) %>
+ <%= if @user.fields do %> +
+ <%= for {key, value} <- @user.fields do %> +
<%= key %>
+
+ <%= if Regex.match?(~r{://}, value) do %> + <%= Regex.replace(~r{^\w+://}, value, "") %> + <% else %> + <%= value %> + <% end %> +
+ <% end %> +
+ <% end %>
-
Posts
-
Posts and replies
-
Media
+ Featured + Posts + Posts and replies + Media
diff --git a/lib/nulla_web/controllers/user_html/show.html.heex.bak b/lib/nulla_web/controllers/user_html/show.html.heex.bak new file mode 100644 index 0000000..0cb7aef --- /dev/null +++ b/lib/nulla_web/controllers/user_html/show.html.heex.bak @@ -0,0 +1,15 @@ +<.header> + User {@user.id} + <:subtitle>This is a user record from your database. + <:actions> + <.link href={~p"/users/#{@user}/edit"}> + <.button>Edit user + + + + +<.list> + <:item title="Username">{@user.username} + + +<.back navigate={~p"/users"}>Back to users diff --git a/lib/nulla_web/controllers/user_html/user_form.html.heex b/lib/nulla_web/controllers/user_html/user_form.html.heex new file mode 100644 index 0000000..6871618 --- /dev/null +++ b/lib/nulla_web/controllers/user_html/user_form.html.heex @@ -0,0 +1,9 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:username]} type="text" label="Username" /> + <:actions> + <.button>Save User + + diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 46a699c..4f956cd 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -19,6 +19,7 @@ defmodule NullaWeb.Router do get "/", PageController, :home get "/@:username", UserController, :show + resources "/users", UserController end # Other scopes may use custom stacks. diff --git a/test/nulla/users_test.exs b/test/nulla/users_test.exs new file mode 100644 index 0000000..653e5cc --- /dev/null +++ b/test/nulla/users_test.exs @@ -0,0 +1,59 @@ +defmodule Nulla.UsersTest do + use Nulla.DataCase + + alias Nulla.Users + + describe "users" do + alias Nulla.Users.User + + import Nulla.UsersFixtures + + @invalid_attrs %{username: nil} + + test "list_users/0 returns all users" do + user = user_fixture() + assert Users.list_users() == [user] + end + + test "get_user!/1 returns the user with given id" do + user = user_fixture() + assert Users.get_user!(user.id) == user + end + + test "create_user/1 with valid data creates a user" do + valid_attrs = %{username: "some username"} + + assert {:ok, %User{} = user} = Users.create_user(valid_attrs) + assert user.username == "some username" + end + + test "create_user/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Users.create_user(@invalid_attrs) + end + + test "update_user/2 with valid data updates the user" do + user = user_fixture() + update_attrs = %{username: "some updated username"} + + assert {:ok, %User{} = user} = Users.update_user(user, update_attrs) + assert user.username == "some updated username" + end + + test "update_user/2 with invalid data returns error changeset" do + user = user_fixture() + assert {:error, %Ecto.Changeset{}} = Users.update_user(user, @invalid_attrs) + assert user == Users.get_user!(user.id) + end + + test "delete_user/1 deletes the user" do + user = user_fixture() + assert {:ok, %User{}} = Users.delete_user(user) + assert_raise Ecto.NoResultsError, fn -> Users.get_user!(user.id) end + end + + test "change_user/1 returns a user changeset" do + user = user_fixture() + assert %Ecto.Changeset{} = Users.change_user(user) + end + end +end diff --git a/test/nulla_web/controllers/user_controller_test.exs b/test/nulla_web/controllers/user_controller_test.exs new file mode 100644 index 0000000..2d67f42 --- /dev/null +++ b/test/nulla_web/controllers/user_controller_test.exs @@ -0,0 +1,84 @@ +defmodule NullaWeb.UserControllerTest do + use NullaWeb.ConnCase + + import Nulla.UsersFixtures + + @create_attrs %{username: "some username"} + @update_attrs %{username: "some updated username"} + @invalid_attrs %{username: nil} + + describe "index" do + test "lists all users", %{conn: conn} do + conn = get(conn, ~p"/users") + assert html_response(conn, 200) =~ "Listing Users" + end + end + + describe "new user" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/users/new") + assert html_response(conn, 200) =~ "New User" + end + end + + describe "create user" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/users", user: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/users/#{id}" + + conn = get(conn, ~p"/users/#{id}") + assert html_response(conn, 200) =~ "User #{id}" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/users", user: @invalid_attrs) + assert html_response(conn, 200) =~ "New User" + end + end + + describe "edit user" do + setup [:create_user] + + test "renders form for editing chosen user", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/#{user}/edit") + assert html_response(conn, 200) =~ "Edit User" + end + end + + describe "update user" do + setup [:create_user] + + test "redirects when data is valid", %{conn: conn, user: user} do + conn = put(conn, ~p"/users/#{user}", user: @update_attrs) + assert redirected_to(conn) == ~p"/users/#{user}" + + conn = get(conn, ~p"/users/#{user}") + assert html_response(conn, 200) =~ "some updated username" + end + + test "renders errors when data is invalid", %{conn: conn, user: user} do + conn = put(conn, ~p"/users/#{user}", user: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit User" + end + end + + describe "delete user" do + setup [:create_user] + + test "deletes chosen user", %{conn: conn, user: user} do + conn = delete(conn, ~p"/users/#{user}") + assert redirected_to(conn) == ~p"/users" + + assert_error_sent 404, fn -> + get(conn, ~p"/users/#{user}") + end + end + end + + defp create_user(_) do + user = user_fixture() + %{user: user} + end +end diff --git a/test/support/fixtures/users_fixtures.ex b/test/support/fixtures/users_fixtures.ex new file mode 100644 index 0000000..ae82587 --- /dev/null +++ b/test/support/fixtures/users_fixtures.ex @@ -0,0 +1,20 @@ +defmodule Nulla.UsersFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Users` context. + """ + + @doc """ + Generate a user. + """ + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> Enum.into(%{ + username: "some username" + }) + |> Nulla.Users.create_user() + + user + end +end From c7c7606e4b9d34dee4a22a8b6fd5c35328528333 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:31:02 +0200 Subject: [PATCH 025/144] Add instance_settings.ex --- lib/nulla/instance_settings.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/nulla/instance_settings.ex b/lib/nulla/instance_settings.ex index d2dfe9d..3e4f495 100644 --- a/lib/nulla/instance_settings.ex +++ b/lib/nulla/instance_settings.ex @@ -1,6 +1,7 @@ defmodule Nulla.InstanceSettings do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo schema "instance_settings" do field :name, :string, default: "Nulla" @@ -17,4 +18,6 @@ defmodule Nulla.InstanceSettings do |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size]) |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size]) end + + def get_instance_settings!, do: Repo.one!(Nulla.InstanceSettings) end From 40a9e0e9614a4608ede89a9a902675e45a916e43 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:31:19 +0200 Subject: [PATCH 026/144] Fix 20250530110822_create_users.exs --- priv/repo/migrations/20250530110822_create_users.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs index 48fa01a..265c22c 100644 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -13,6 +13,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :location, :string add :birthday, :date add :fields, :map + add :tags, {:array, :string} add :follow_approval, :boolean, default: false, null: false add :is_bot, :boolean, default: false, null: false add :is_discoverable, :boolean, default: true, null: false @@ -20,7 +21,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :is_memorial, :boolean, default: false, null: false add :private_key, :string, null: false add :public_key, :string, null: false - add :avater, :string + add :avatar, :string add :banner, :string timestamps(type: :utc_datetime) From 5b0427ad59cc373a92c34fb8380fd9b26d5ab367 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:31:39 +0200 Subject: [PATCH 027/144] Add activitypub.ex --- lib/nulla/activitypub.ex | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/nulla/activitypub.ex diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex new file mode 100644 index 0000000..85647e1 --- /dev/null +++ b/lib/nulla/activitypub.ex @@ -0,0 +1,84 @@ +defmodule Nulla.ActivityPub do + @spec context() :: list() + defp context do + [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + Jason.OrderedObject.new( + manuallyApprovesFollowers: "as:manuallyApprovesFollowers", + toot: "http://joinmastodon.org/ns#", + featured: %{"@id" => "toot:featured", "@type" => "@id"}, + featuredTags: %{"@id" => "toot:featuredTags", "@type" => "@id"}, + alsoKnownAs: %{"@id" => "as:alsoKnownAs", "@type" => "@id"}, + movedTo: %{"@id" => "as:movedTo", "@type" => "@id"}, + schema: "http://schema.org#", + PropertyValue: "schema:PropertyValue", + value: "schema:value", + discoverable: "toot:discoverable", + suspended: "toot:suspended", + memorial: "toot:memorial", + indexable: "toot:indexable", + attributionDomains: %{"@id" => "toot:attributionDomains", "@type" => "@id"}, + Hashtag: "as:Hashtag", + focalPoint: %{"@container" => "@list", "@id" => "toot:focalPoint"} + ) + ] + end + + @spec ap_user(String.t(), Nulla.Users.User.t()) :: Jason.OrderedObject.t() + def ap_user(domain, user) do + Jason.OrderedObject.new([ + "@context": context(), + id: "https://#{domain}/@#{user.username}", + type: "Person", + following: "https://#{domain}/@#{user.username}/following", + followers: "https://#{domain}/@#{user.username}/followers", + inbox: "https://#{domain}/@#{user.username}/inbox", + outbox: "https://#{domain}/@#{user.username}/outbox", + featured: "https://#{domain}/@#{user.username}/collections/featured", + preferredUsername: user.username, + name: user.realname, + summary: user.bio, + url: "https://#{domain}/@#{user.username}", + manuallyApprovesFollowers: user.follow_approval, + discoverable: user.is_discoverable, + indexable: user.is_indexable, + published: DateTime.to_iso8601(user.inserted_at), + memorial: user.is_memorial, + publicKey: Jason.OrderedObject.new( + id: "https://#{domain}/@#{user.username}#main-key", + owner: "https://#{domain}/@#{user.username}", + publicKeyPem: user.public_key + ), + tag: Enum.map(user.tags, fn tag -> + Jason.OrderedObject.new( + type: "Hashtag", + href: "https://#{domain}/tags/#{tag}", + name: "##{tag}" + ) + end), + attachment: Enum.map(user.fields, fn {name, value} -> + Jason.OrderedObject.new( + type: "PropertyValue", + name: name, + value: value + ) + end), + endpoints: Jason.OrderedObject.new( + sharedInbox: "https://#{domain}/inbox" + ), + icon: Jason.OrderedObject.new( + type: "Image", + mediaType: MIME.from_path(user.avatar), + url: "https://#{domain}#{user.avatar}" + ), + image: Jason.OrderedObject.new( + type: "Image", + mediaType: MIME.from_path(user.banner), + url: "https://#{domain}#{user.banner}" + ), + "vcard:bday": user.birthday, + "vcard:Address": user.location + ]) + end +end From 37ddc0188f3a00ee3418d99a199f1091b9e15494 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:34:49 +0200 Subject: [PATCH 028/144] Remove user_html/show.html.heex.bak --- .../controllers/user_html/show.html.heex.bak | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 lib/nulla_web/controllers/user_html/show.html.heex.bak diff --git a/lib/nulla_web/controllers/user_html/show.html.heex.bak b/lib/nulla_web/controllers/user_html/show.html.heex.bak deleted file mode 100644 index 0cb7aef..0000000 --- a/lib/nulla_web/controllers/user_html/show.html.heex.bak +++ /dev/null @@ -1,15 +0,0 @@ -<.header> - User {@user.id} - <:subtitle>This is a user record from your database. - <:actions> - <.link href={~p"/users/#{@user}/edit"}> - <.button>Edit user - - - - -<.list> - <:item title="Username">{@user.username} - - -<.back navigate={~p"/users"}>Back to users From 4a5d5e9d6422f95072933b6ebca37bd29cf71fac Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 2 Jun 2025 17:35:23 +0200 Subject: [PATCH 029/144] Remove user_controller.ex.bak --- .../controllers/user_controller.ex.bak | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 lib/nulla_web/controllers/user_controller.ex.bak diff --git a/lib/nulla_web/controllers/user_controller.ex.bak b/lib/nulla_web/controllers/user_controller.ex.bak deleted file mode 100644 index 093ba20..0000000 --- a/lib/nulla_web/controllers/user_controller.ex.bak +++ /dev/null @@ -1,62 +0,0 @@ -defmodule NullaWeb.UserController do - use NullaWeb, :controller - - alias Nulla.Users - alias Nulla.Users.User - - def index(conn, _params) do - users = Users.list_users() - render(conn, :index, users: users) - end - - def new(conn, _params) do - changeset = Users.change_user(%User{}) - render(conn, :new, changeset: changeset) - end - - def create(conn, %{"user" => user_params}) do - case Users.create_user(user_params) do - {:ok, user} -> - conn - |> put_flash(:info, "User created successfully.") - |> redirect(to: ~p"/users/#{user}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) - end - end - - def show(conn, %{"id" => id}) do - user = Users.get_user!(id) - render(conn, :show, user: user) - end - - def edit(conn, %{"id" => id}) do - user = Users.get_user!(id) - changeset = Users.change_user(user) - render(conn, :edit, user: user, changeset: changeset) - end - - def update(conn, %{"id" => id, "user" => user_params}) do - user = Users.get_user!(id) - - case Users.update_user(user, user_params) do - {:ok, user} -> - conn - |> put_flash(:info, "User updated successfully.") - |> redirect(to: ~p"/users/#{user}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :edit, user: user, changeset: changeset) - end - end - - def delete(conn, %{"id" => id}) do - user = Users.get_user!(id) - {:ok, _user} = Users.delete_user(user) - - conn - |> put_flash(:info, "User deleted successfully.") - |> redirect(to: ~p"/users") - end -end From 6ac19d626194a5a806c90648f8a199718a11a243 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 15:54:03 +0200 Subject: [PATCH 030/144] Update .gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f3111c9..43182ba 100644 --- a/.gitignore +++ b/.gitignore @@ -28,10 +28,12 @@ nulla-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ +# Ignore static files +/priv/static/files/ + # Ignore digested assets cache. /priv/static/cache_manifest.json # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ - From 2bef6b9d6d066158ebd3b88ca21d348db9677584 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 15:54:43 +0200 Subject: [PATCH 031/144] Remove static files --- priv/static/images/avatar.jpg | Bin 37876 -> 0 bytes priv/static/images/banner.jpg | Bin 22671 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 priv/static/images/avatar.jpg delete mode 100644 priv/static/images/banner.jpg diff --git a/priv/static/images/avatar.jpg b/priv/static/images/avatar.jpg deleted file mode 100644 index 3df25a703e942962d6a42bd5d4d5e39472f40fcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37876 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≪R?)*F&}`Rtp;C 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

|$eCQH^J(LM2 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 diff --git a/priv/static/images/banner.jpg b/priv/static/images/banner.jpg deleted file mode 100644 index df9b751907c61feba52a43074a8ecb5b186b7ca6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22671 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 From 762914c3c65512bd6ef6c08918e2732fd82141f8 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:04:36 +0200 Subject: [PATCH 032/144] Remove tests --- test/nulla/users_test.exs | 59 ------------- .../controllers/page_controller_test.exs | 8 -- .../controllers/user_controller_test.exs | 84 ------------------- test/support/fixtures/users_fixtures.ex | 20 ----- 4 files changed, 171 deletions(-) delete mode 100644 test/nulla/users_test.exs delete mode 100644 test/nulla_web/controllers/page_controller_test.exs delete mode 100644 test/nulla_web/controllers/user_controller_test.exs delete mode 100644 test/support/fixtures/users_fixtures.ex diff --git a/test/nulla/users_test.exs b/test/nulla/users_test.exs deleted file mode 100644 index 653e5cc..0000000 --- a/test/nulla/users_test.exs +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Nulla.UsersTest do - use Nulla.DataCase - - alias Nulla.Users - - describe "users" do - alias Nulla.Users.User - - import Nulla.UsersFixtures - - @invalid_attrs %{username: nil} - - test "list_users/0 returns all users" do - user = user_fixture() - assert Users.list_users() == [user] - end - - test "get_user!/1 returns the user with given id" do - user = user_fixture() - assert Users.get_user!(user.id) == user - end - - test "create_user/1 with valid data creates a user" do - valid_attrs = %{username: "some username"} - - assert {:ok, %User{} = user} = Users.create_user(valid_attrs) - assert user.username == "some username" - end - - test "create_user/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Users.create_user(@invalid_attrs) - end - - test "update_user/2 with valid data updates the user" do - user = user_fixture() - update_attrs = %{username: "some updated username"} - - assert {:ok, %User{} = user} = Users.update_user(user, update_attrs) - assert user.username == "some updated username" - end - - test "update_user/2 with invalid data returns error changeset" do - user = user_fixture() - assert {:error, %Ecto.Changeset{}} = Users.update_user(user, @invalid_attrs) - assert user == Users.get_user!(user.id) - end - - test "delete_user/1 deletes the user" do - user = user_fixture() - assert {:ok, %User{}} = Users.delete_user(user) - assert_raise Ecto.NoResultsError, fn -> Users.get_user!(user.id) end - end - - test "change_user/1 returns a user changeset" do - user = user_fixture() - assert %Ecto.Changeset{} = Users.change_user(user) - end - end -end diff --git a/test/nulla_web/controllers/page_controller_test.exs b/test/nulla_web/controllers/page_controller_test.exs deleted file mode 100644 index 5e8da73..0000000 --- a/test/nulla_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule NullaWeb.PageControllerTest do - use NullaWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" - end -end diff --git a/test/nulla_web/controllers/user_controller_test.exs b/test/nulla_web/controllers/user_controller_test.exs deleted file mode 100644 index 2d67f42..0000000 --- a/test/nulla_web/controllers/user_controller_test.exs +++ /dev/null @@ -1,84 +0,0 @@ -defmodule NullaWeb.UserControllerTest do - use NullaWeb.ConnCase - - import Nulla.UsersFixtures - - @create_attrs %{username: "some username"} - @update_attrs %{username: "some updated username"} - @invalid_attrs %{username: nil} - - describe "index" do - test "lists all users", %{conn: conn} do - conn = get(conn, ~p"/users") - assert html_response(conn, 200) =~ "Listing Users" - end - end - - describe "new user" do - test "renders form", %{conn: conn} do - conn = get(conn, ~p"/users/new") - assert html_response(conn, 200) =~ "New User" - end - end - - describe "create user" do - test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, ~p"/users", user: @create_attrs) - - assert %{id: id} = redirected_params(conn) - assert redirected_to(conn) == ~p"/users/#{id}" - - conn = get(conn, ~p"/users/#{id}") - assert html_response(conn, 200) =~ "User #{id}" - end - - test "renders errors when data is invalid", %{conn: conn} do - conn = post(conn, ~p"/users", user: @invalid_attrs) - assert html_response(conn, 200) =~ "New User" - end - end - - describe "edit user" do - setup [:create_user] - - test "renders form for editing chosen user", %{conn: conn, user: user} do - conn = get(conn, ~p"/users/#{user}/edit") - assert html_response(conn, 200) =~ "Edit User" - end - end - - describe "update user" do - setup [:create_user] - - test "redirects when data is valid", %{conn: conn, user: user} do - conn = put(conn, ~p"/users/#{user}", user: @update_attrs) - assert redirected_to(conn) == ~p"/users/#{user}" - - conn = get(conn, ~p"/users/#{user}") - assert html_response(conn, 200) =~ "some updated username" - end - - test "renders errors when data is invalid", %{conn: conn, user: user} do - conn = put(conn, ~p"/users/#{user}", user: @invalid_attrs) - assert html_response(conn, 200) =~ "Edit User" - end - end - - describe "delete user" do - setup [:create_user] - - test "deletes chosen user", %{conn: conn, user: user} do - conn = delete(conn, ~p"/users/#{user}") - assert redirected_to(conn) == ~p"/users" - - assert_error_sent 404, fn -> - get(conn, ~p"/users/#{user}") - end - end - end - - defp create_user(_) do - user = user_fixture() - %{user: user} - end -end diff --git a/test/support/fixtures/users_fixtures.ex b/test/support/fixtures/users_fixtures.ex deleted file mode 100644 index ae82587..0000000 --- a/test/support/fixtures/users_fixtures.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Nulla.UsersFixtures do - @moduledoc """ - This module defines test helpers for creating - entities via the `Nulla.Users` context. - """ - - @doc """ - Generate a user. - """ - def user_fixture(attrs \\ %{}) do - {:ok, user} = - attrs - |> Enum.into(%{ - username: "some username" - }) - |> Nulla.Users.create_user() - - user - end -end From 182523d36dd745d6ee48c6c592f0d7663ba5c3cf Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:06:24 +0200 Subject: [PATCH 033/144] Remove page_* --- lib/nulla_web/controllers/page_controller.ex | 11 ---------- lib/nulla_web/controllers/page_html.ex | 10 ---------- .../controllers/page_html/home.html.heex | 20 ------------------- 3 files changed, 41 deletions(-) delete mode 100644 lib/nulla_web/controllers/page_controller.ex delete mode 100644 lib/nulla_web/controllers/page_html.ex delete mode 100644 lib/nulla_web/controllers/page_html/home.html.heex diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex deleted file mode 100644 index 76dd567..0000000 --- a/lib/nulla_web/controllers/page_controller.ex +++ /dev/null @@ -1,11 +0,0 @@ -defmodule NullaWeb.PageController do - use NullaWeb, :controller - - def home(conn, _params) do - render(conn, :home, layout: false) - end - - def profile(conn, %{"username" => _username}) do - render(conn, :profile, layout: false) - end -end diff --git a/lib/nulla_web/controllers/page_html.ex b/lib/nulla_web/controllers/page_html.ex deleted file mode 100644 index fbb6c6e..0000000 --- a/lib/nulla_web/controllers/page_html.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule NullaWeb.PageHTML do - @moduledoc """ - This module contains pages rendered by PageController. - - See the `page_html` directory for all templates available. - """ - use NullaWeb, :html - - embed_templates "page_html/*" -end diff --git a/lib/nulla_web/controllers/page_html/home.html.heex b/lib/nulla_web/controllers/page_html/home.html.heex deleted file mode 100644 index 6bcfbd1..0000000 --- a/lib/nulla_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,20 +0,0 @@ -<.flash_group flash={@flash} /> -

-
- -
- -
- -
-
-
-
- -
-
-
- -
-
-
From 9e542bc7909ac1a3e1cbbedefc354a175cc36b00 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:07:04 +0200 Subject: [PATCH 034/144] Add models and migrations --- lib/nulla/activitypub.ex | 41 ++++++- lib/nulla/models/bookmarks.ex | 23 ++++ lib/nulla/models/follow.ex | 19 ++++ lib/nulla/models/hashtag.ex | 19 ++++ lib/nulla/{ => models}/instance_settings.ex | 11 +- lib/nulla/models/media_attachment.ex | 20 ++++ lib/nulla/models/moderation_log.ex | 23 ++++ lib/nulla/models/note.ex | 29 +++++ lib/nulla/models/notification.ex | 22 ++++ lib/nulla/models/session.ex | 21 ++++ lib/nulla/{users => models}/user.ex | 11 +- lib/nulla/users.ex | 106 ------------------ lib/nulla_web.ex | 2 +- lib/nulla_web/controllers/note_controller.ex | 62 ++++++++++ lib/nulla_web/controllers/note_html.ex | 13 +++ .../controllers/note_html/edit.html.heex | 8 ++ .../controllers/note_html/index.html.heex | 23 ++++ .../controllers/note_html/new.html.heex | 8 ++ .../controllers/note_html/note_form.html.heex | 9 ++ .../controllers/note_html/show.html.heex | 15 +++ lib/nulla_web/controllers/user_controller.ex | 10 +- lib/nulla_web/controllers/user_html.ex | 38 +++++++ .../controllers/user_html/show.html.heex | 53 ++++++++- ...250527054942_create_instance_settings.exs} | 4 + .../20250530110822_create_users.exs | 1 - .../20250604083506_create_notes.exs | 18 +++ .../20250606100445_create_bookmarks.exs | 14 +++ .../20250606103230_create_notifications.exs | 18 +++ ...20250606103527_create_moderations_logs.exs | 19 ++++ .../20250606103649_create_hashtags.exs | 14 +++ .../20250606103707_create_follows.exs | 15 +++ .../20250606131715_create_sessions.exs | 17 +++ ...0250606132108_create_media_attachments.exs | 16 +++ 33 files changed, 597 insertions(+), 125 deletions(-) create mode 100644 lib/nulla/models/bookmarks.ex create mode 100644 lib/nulla/models/follow.ex create mode 100644 lib/nulla/models/hashtag.ex rename lib/nulla/{ => models}/instance_settings.ex (64%) create mode 100644 lib/nulla/models/media_attachment.ex create mode 100644 lib/nulla/models/moderation_log.ex create mode 100644 lib/nulla/models/note.ex create mode 100644 lib/nulla/models/notification.ex create mode 100644 lib/nulla/models/session.ex rename lib/nulla/{users => models}/user.ex (80%) delete mode 100644 lib/nulla/users.ex create mode 100644 lib/nulla_web/controllers/note_controller.ex create mode 100644 lib/nulla_web/controllers/note_html.ex create mode 100644 lib/nulla_web/controllers/note_html/edit.html.heex create mode 100644 lib/nulla_web/controllers/note_html/index.html.heex create mode 100644 lib/nulla_web/controllers/note_html/new.html.heex create mode 100644 lib/nulla_web/controllers/note_html/note_form.html.heex create mode 100644 lib/nulla_web/controllers/note_html/show.html.heex rename priv/repo/migrations/{20250527054942_create_instance.exs => 20250527054942_create_instance_settings.exs} (86%) create mode 100644 priv/repo/migrations/20250604083506_create_notes.exs create mode 100644 priv/repo/migrations/20250606100445_create_bookmarks.exs create mode 100644 priv/repo/migrations/20250606103230_create_notifications.exs create mode 100644 priv/repo/migrations/20250606103527_create_moderations_logs.exs create mode 100644 priv/repo/migrations/20250606103649_create_hashtags.exs create mode 100644 priv/repo/migrations/20250606103707_create_follows.exs create mode 100644 priv/repo/migrations/20250606131715_create_sessions.exs create mode 100644 priv/repo/migrations/20250606132108_create_media_attachments.exs diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 85647e1..2bea870 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -20,12 +20,12 @@ defmodule Nulla.ActivityPub do indexable: "toot:indexable", attributionDomains: %{"@id" => "toot:attributionDomains", "@type" => "@id"}, Hashtag: "as:Hashtag", - focalPoint: %{"@container" => "@list", "@id" => "toot:focalPoint"} + vcard: "http://www.w3.org/2006/vcard/ns#" ) ] end - @spec ap_user(String.t(), Nulla.Users.User.t()) :: Jason.OrderedObject.t() + @spec ap_user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() def ap_user(domain, user) do Jason.OrderedObject.new([ "@context": context(), @@ -81,4 +81,41 @@ defmodule Nulla.ActivityPub do "vcard:Address": user.location ]) end + + @spec note(String.t(), Nulla.Models.User.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + def note(domain, user, note) do + Jason.OrderedObject.new([ + "@context": [ + "https://www.w3.org/ns/activitystreams", + Jason.OrderedObject.new( + sensitive: "as:sensitive" + ) + ], + id: "https://#{domain}/@#{user.username}/#{note.id}", + type: "Note", + summary: nil, + inReplyTo: nil, + published: note.inserted_at, + url: "https://#{domain}/@#{user.username}/#{note.id}", + attributedTo: "https://#{domain}/@#{user.username}", + to: [ + "https://www.w3.org/ns/activitystreams#Public" + ], + cc: [ + "https://#{domain}/@#{user.username}/followers" + ], + sensetive: false, + content: note.content, + contentMap: Jason.OrderedObject.new( + "#{note.language}": "

@rf@mastodonsocial.ru Вниманию новичкам!

Вам небольшое руководство о том, как импротировать пост, которого нет в вашей ленте.

" + ), + attachment: [ + Jason.OrderedObject.new( + type: "Document", + mediaType: "video/mp4", + url: "https://mastodon.ml/system/media_attachments/files/000/040/494/original/8c06de179c11daea.mp4" + ) + ] + ]) + end end diff --git a/lib/nulla/models/bookmarks.ex b/lib/nulla/models/bookmarks.ex new file mode 100644 index 0000000..b4e7a5b --- /dev/null +++ b/lib/nulla/models/bookmarks.ex @@ -0,0 +1,23 @@ +defmodule Nulla.Models.Bookmark do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Nulla.Repo + alias Nulla.Models.Bookmark + + schema "bookmarks" do + field :url, :string + field :user_id, :id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(bookmark, attrs) do + bookmark + |> cast(attrs, [:url, :user_id]) + |> validate_required([:url, :user_id]) + end + + def get_all_bookmarks!(user_id), do: Repo.all(from n in Bookmark, where: n.user_id == ^user_id) +end diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex new file mode 100644 index 0000000..0469dbd --- /dev/null +++ b/lib/nulla/models/follow.ex @@ -0,0 +1,19 @@ +defmodule Nulla.Models.Follow do + use Ecto.Schema + import Ecto.Changeset + + schema "follows" do + belongs_to :user, Nulla.Models.User + belongs_to :target, Nulla.Models.User + + timestamps() + end + + @doc false + def changeset(follow, attrs) do + follow + |> cast(attrs, [:user_id, :target_id]) + |> validate_required([:user_id, :target_id]) + |> unique_constraint([:user_id, :target_id]) + end +end diff --git a/lib/nulla/models/hashtag.ex b/lib/nulla/models/hashtag.ex new file mode 100644 index 0000000..0be647d --- /dev/null +++ b/lib/nulla/models/hashtag.ex @@ -0,0 +1,19 @@ +defmodule Nulla.Models.Hashtag do + use Ecto.Schema + import Ecto.Changeset + + schema "hashtags" do + field :tag, :string + field :usage_count, :integer, default: 0 + + timestamps() + end + + @doc false + def changeset(hashtag, attrs) do + hashtag + |> cast(attrs, [:tag, :usage_count]) + |> validate_required([:tag]) + |> unique_constraint(:tag) + end +end diff --git a/lib/nulla/instance_settings.ex b/lib/nulla/models/instance_settings.ex similarity index 64% rename from lib/nulla/instance_settings.ex rename to lib/nulla/models/instance_settings.ex index 3e4f495..96280c1 100644 --- a/lib/nulla/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -1,7 +1,8 @@ -defmodule Nulla.InstanceSettings do +defmodule Nulla.Models.InstanceSettings do use Ecto.Schema import Ecto.Changeset alias Nulla.Repo + alias Nulla.Models.InstanceSettings schema "instance_settings" do field :name, :string, default: "Nulla" @@ -10,14 +11,16 @@ defmodule Nulla.InstanceSettings do field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 + field :public_key, :string + field :private_key, :string end @doc false def changeset(instance_settings, attrs) do instance_settings - |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size]) - |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size]) + |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key]) + |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key]) end - def get_instance_settings!, do: Repo.one!(Nulla.InstanceSettings) + def get_instance_settings!, do: Repo.one!(InstanceSettings) end diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/models/media_attachment.ex new file mode 100644 index 0000000..9eca245 --- /dev/null +++ b/lib/nulla/models/media_attachment.ex @@ -0,0 +1,20 @@ +defmodule Nulla.Models.MediaAttachment do + use Ecto.Schema + import Ecto.Changeset + + schema "media_attachments" do + field :file, :string + field :mime_type, :string + field :description, :string + + belongs_to :note, Nulla.Models.Note + + timestamps(type: :utc_datetime) + end + + def changeset(media, attrs) do + media + |> cast(attrs, [:note_id, :file, :mime_type, :description]) + |> validate_required([:note_id, :file]) + end +end diff --git a/lib/nulla/models/moderation_log.ex b/lib/nulla/models/moderation_log.ex new file mode 100644 index 0000000..f6d3469 --- /dev/null +++ b/lib/nulla/models/moderation_log.ex @@ -0,0 +1,23 @@ +defmodule Nulla.Models.ModerationLog do + use Ecto.Schema + import Ecto.Changeset + + schema "moderation_logs" do + field :target_type, :string + field :target_id, :string + field :action, :string + field :reason, :string + field :metadata, :map + + belongs_to :moderator, Nulla.Models.User + + timestamps() + end + + @doc false + def changeset(moderation_log, attrs) do + moderation_log + |> cast(attrs, [:moderator_id, :target_type, :target_id, :action, :reason, :metadata]) + |> validate_required([:moderator_id, :target_type, :target_id, :action]) + end +end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex new file mode 100644 index 0000000..f4d5960 --- /dev/null +++ b/lib/nulla/models/note.ex @@ -0,0 +1,29 @@ +defmodule Nulla.Models.Note do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Nulla.Repo + alias Nulla.Models.Note + + schema "notes" do + field :content, :string + field :visibility, Ecto.Enum, values: [:public, :unlisted, :followers, :private], default: :public + field :sensitive, :boolean, default: false + field :language, :string + field :in_reply_to, :string + + belongs_to :user, Nulla.Models.User + has_many :media_attachments, Nulla.Models.MediaAttachment + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) + |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) + end + + def get_all_notes!(user_id), do: Repo.all(from n in Note, where: n.user_id == ^user_id) +end diff --git a/lib/nulla/models/notification.ex b/lib/nulla/models/notification.ex new file mode 100644 index 0000000..c0d595c --- /dev/null +++ b/lib/nulla/models/notification.ex @@ -0,0 +1,22 @@ +defmodule Nulla.Models.Notification do + use Ecto.Schema + import Ecto.Changeset + + schema "notifications" do + field :type, :string + field :data, :map + field :read, :boolean, default: false + + belongs_to :user, Nulla.Models.User + belongs_to :actor, Nulla.Models.User + + timestamps() + end + + @doc false + def changeset(notification, attrs) do + notification + |> cast(attrs, [:user_id, :actor_id, :type, :data, :read]) + |> validate_required([:user_id, :type]) + end +end diff --git a/lib/nulla/models/session.ex b/lib/nulla/models/session.ex new file mode 100644 index 0000000..c505ad6 --- /dev/null +++ b/lib/nulla/models/session.ex @@ -0,0 +1,21 @@ +defmodule Nulla.Models.Session do + use Ecto.Schema + import Ecto.Changeset + + schema "sessions" do + field :token, :string + field :user_agent, :string + field :ip, :string + + belongs_to :user, Nulla.Models.User + + timestamps(type: :utc_datetime) + end + + def changeset(session, attrs) do + session + |> cast(attrs, [:user_id, :token, :user_agent, :ip]) + |> validate_required([:user_id, :token]) + |> unique_constraint(:token) + end +end diff --git a/lib/nulla/users/user.ex b/lib/nulla/models/user.ex similarity index 80% rename from lib/nulla/users/user.ex rename to lib/nulla/models/user.ex index b03f068..d350ad0 100644 --- a/lib/nulla/users/user.ex +++ b/lib/nulla/models/user.ex @@ -1,13 +1,14 @@ -defmodule Nulla.Users.User do +defmodule Nulla.Models.User do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo + alias Nulla.Models.User schema "users" do field :username, :string field :email, :string field :password, :string field :is_moderator, :boolean, default: false - field :realname, :string field :bio, :string field :location, :string @@ -24,6 +25,10 @@ defmodule Nulla.Users.User do field :avatar, :string field :banner, :string + has_many :user_sessions, Nulla.Models.Session + has_many :notes, Nulla.Models.Note + has_many :media_attachments, through: [:notes, :media_attachments] + timestamps(type: :utc_datetime) end @@ -33,4 +38,6 @@ defmodule Nulla.Users.User do |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) end + + def get_user_by_username!(username), do: Repo.get_by!(User, username: username) end diff --git a/lib/nulla/users.ex b/lib/nulla/users.ex deleted file mode 100644 index 292db75..0000000 --- a/lib/nulla/users.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Nulla.Users do - @moduledoc """ - The Users context. - """ - - import Ecto.Query, warn: false - alias Nulla.Repo - - alias Nulla.Users.User - - @doc """ - Returns the list of users. - - ## Examples - - iex> list_users() - [%User{}, ...] - - """ - def list_users do - Repo.all(User) - end - - @doc """ - Gets a single user. - - Raises `Ecto.NoResultsError` if the User does not exist. - - ## Examples - - iex> get_user!(123) - %User{} - - iex> get_user!(456) - ** (Ecto.NoResultsError) - - """ - def get_user!(id), do: Repo.get!(User, id) - - def get_user_by_username!(username), do: Repo.get_by!(User, username: username) - - @doc """ - Creates a user. - - ## Examples - - iex> create_user(%{field: value}) - {:ok, %User{}} - - iex> create_user(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_user(attrs \\ %{}) do - %User{} - |> User.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a user. - - ## Examples - - iex> update_user(user, %{field: new_value}) - {:ok, %User{}} - - iex> update_user(user, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_user(%User{} = user, attrs) do - user - |> User.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a user. - - ## Examples - - iex> delete_user(user) - {:ok, %User{}} - - iex> delete_user(user) - {:error, %Ecto.Changeset{}} - - """ - def delete_user(%User{} = user) do - Repo.delete(user) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking user changes. - - ## Examples - - iex> change_user(user) - %Ecto.Changeset{data: %User{}} - - """ - def change_user(%User{} = user, attrs \\ %{}) do - User.changeset(user, attrs) - end -end diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex index 862aea6..9feaa88 100644 --- a/lib/nulla_web.ex +++ b/lib/nulla_web.ex @@ -17,7 +17,7 @@ defmodule NullaWeb do those modules here. """ - def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets files fonts images favicon.ico robots.txt) def router do quote do diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex new file mode 100644 index 0000000..814a1dd --- /dev/null +++ b/lib/nulla_web/controllers/note_controller.ex @@ -0,0 +1,62 @@ +defmodule NullaWeb.NoteController do + use NullaWeb, :controller + + alias Nulla.Notes + alias Nulla.Models.Note + + def index(conn, _params) do + notes = Notes.list_notes() + render(conn, :index, notes: notes) + end + + def new(conn, _params) do + changeset = Notes.change_note(%Note{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"note" => note_params}) do + case Notes.create_note(note_params) do + {:ok, note} -> + conn + |> put_flash(:info, "Note created successfully.") + |> redirect(to: ~p"/notes/#{note}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + note = Notes.get_note!(id) + render(conn, :show, note: note) + end + + def edit(conn, %{"id" => id}) do + note = Notes.get_note!(id) + changeset = Notes.change_note(note) + render(conn, :edit, note: note, changeset: changeset) + end + + def update(conn, %{"id" => id, "note" => note_params}) do + note = Notes.get_note!(id) + + case Notes.update_note(note, note_params) do + {:ok, note} -> + conn + |> put_flash(:info, "Note updated successfully.") + |> redirect(to: ~p"/notes/#{note}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, note: note, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + note = Notes.get_note!(id) + {:ok, _note} = Notes.delete_note(note) + + conn + |> put_flash(:info, "Note deleted successfully.") + |> redirect(to: ~p"/notes") + end +end diff --git a/lib/nulla_web/controllers/note_html.ex b/lib/nulla_web/controllers/note_html.ex new file mode 100644 index 0000000..447c9ab --- /dev/null +++ b/lib/nulla_web/controllers/note_html.ex @@ -0,0 +1,13 @@ +defmodule NullaWeb.NoteHTML do + use NullaWeb, :html + + embed_templates "note_html/*" + + @doc """ + Renders a note form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def note_form(assigns) +end diff --git a/lib/nulla_web/controllers/note_html/edit.html.heex b/lib/nulla_web/controllers/note_html/edit.html.heex new file mode 100644 index 0000000..3bef388 --- /dev/null +++ b/lib/nulla_web/controllers/note_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Note {@note.id} + <:subtitle>Use this form to manage note records in your database. + + +<.note_form changeset={@changeset} action={~p"/notes/#{@note}"} /> + +<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/controllers/note_html/index.html.heex b/lib/nulla_web/controllers/note_html/index.html.heex new file mode 100644 index 0000000..ffeedbc --- /dev/null +++ b/lib/nulla_web/controllers/note_html/index.html.heex @@ -0,0 +1,23 @@ +<.header> + Listing Notes + <:actions> + <.link href={~p"/notes/new"}> + <.button>New Note + + + + +<.table id="notes" rows={@notes} row_click={&JS.navigate(~p"/notes/#{&1}")}> + <:col :let={note} label="Content">{note.content} + <:action :let={note}> +
+ <.link navigate={~p"/notes/#{note}"}>Show +
+ <.link navigate={~p"/notes/#{note}/edit"}>Edit + + <:action :let={note}> + <.link href={~p"/notes/#{note}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/nulla_web/controllers/note_html/new.html.heex b/lib/nulla_web/controllers/note_html/new.html.heex new file mode 100644 index 0000000..4cf47a4 --- /dev/null +++ b/lib/nulla_web/controllers/note_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Note + <:subtitle>Use this form to manage note records in your database. + + +<.note_form changeset={@changeset} action={~p"/notes"} /> + +<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/controllers/note_html/note_form.html.heex b/lib/nulla_web/controllers/note_html/note_form.html.heex new file mode 100644 index 0000000..da6ac0f --- /dev/null +++ b/lib/nulla_web/controllers/note_html/note_form.html.heex @@ -0,0 +1,9 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:content]} type="text" label="Content" /> + <:actions> + <.button>Save Note + + diff --git a/lib/nulla_web/controllers/note_html/show.html.heex b/lib/nulla_web/controllers/note_html/show.html.heex new file mode 100644 index 0000000..d7f2f70 --- /dev/null +++ b/lib/nulla_web/controllers/note_html/show.html.heex @@ -0,0 +1,15 @@ +<.header> + Note {@note.id} + <:subtitle>This is a note record from your database. + <:actions> + <.link href={~p"/notes/#{@note}/edit"}> + <.button>Edit note + + + + +<.list> + <:item title="Content">{@note.content} + + +<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 76e8b80..0f078e0 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -1,21 +1,23 @@ defmodule NullaWeb.UserController do use NullaWeb, :controller - alias Nulla.Users - alias Nulla.InstanceSettings + alias Nulla.Models.User + alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings alias Nulla.ActivityPub def show(conn, %{"username" => username}) do accept = List.first(get_req_header(conn, "accept")) instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = Users.get_user_by_username!(username) + user = User.get_user_by_username!(username) + notes = Note.get_all_notes!(user.id) if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") |> json(ActivityPub.ap_user(domain, user)) else - render(conn, :show, user: user, domain: domain, layout: false) + render(conn, :show, domain: domain, user: user, notes: notes, layout: false) end end end diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/controllers/user_html.ex index db0df5b..fe6103e 100644 --- a/lib/nulla_web/controllers/user_html.ex +++ b/lib/nulla_web/controllers/user_html.ex @@ -38,4 +38,42 @@ defmodule NullaWeb.UserHTML do "#{formatted} (#{relative})" end + + def format_note_datetime(datetime) do + Timex.format!(datetime, "{0D} {Mfull} {YYYY}, {h24}:{m}", :strftime) + end + + def format_note_datetime_diff(datetime) do + now = Timex.now() + diff = Timex.diff(now, datetime, :seconds) + + cond do + diff < 60 -> + "now" + + diff < 3600 -> + minutes = div(diff, 60) + "#{minutes}m ago" + + diff < 86400 -> + hours = div(diff, 3600) + "#{hours}h ago" + + diff < 518400 -> + days = div(diff, 86400) + "#{days}d ago" + + diff < 2419200 -> + weeks = div(diff, 604800) + "#{weeks}w ago" + + diff < 28512000 -> + months = Timex.diff(now, datetime, :months) + "#{months}mo ago" + + true -> + years = Timex.diff(now, datetime, :years) + "#{years}y ago" + end + end end diff --git a/lib/nulla_web/controllers/user_html/show.html.heex b/lib/nulla_web/controllers/user_html/show.html.heex index 708842f..9ce9f87 100644 --- a/lib/nulla_web/controllers/user_html/show.html.heex +++ b/lib/nulla_web/controllers/user_html/show.html.heex @@ -10,8 +10,11 @@
- - + +
+ + +
{@user.realname} @@ -26,14 +29,12 @@
<%= @user.location %>
<% end %> - <%= if @user.birthday do %>
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
<%= format_birthdate(@user.birthday) %>
<% end %> -
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
@@ -59,12 +60,54 @@ 31 Followers
-
+ +
+ <%= for note <- @notes do %> +
+
+ +
+
+
+ + <%= @user.realname %> + + + @<%= @user.username %>@<%= @domain %> + +
+
+ <%= case note.visibility do %> + <% :public -> %> + <.icon name="hero-globe-americas" class="h-5 w-5" /> + <% :unlisted -> %> + <.icon name="hero-moon" class="h-5 w-5" /> + <% :followers -> %> + <.icon name="hero-lock-closed" class="h-5 w-5" /> + <% :private -> %> + <.icon name="hero-at-symbol" class="h-5 w-5" /> + <% end %> + <%= format_note_datetime_diff(note.inserted_at) %> +
+
+
+

<%= note.content %>

+
+
+ + + +
+
+
+
+ <% end %> +
diff --git a/priv/repo/migrations/20250527054942_create_instance.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs similarity index 86% rename from priv/repo/migrations/20250527054942_create_instance.exs rename to priv/repo/migrations/20250527054942_create_instance_settings.exs index 4fe6dc8..b316d05 100644 --- a/priv/repo/migrations/20250527054942_create_instance.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -9,6 +9,10 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do add :registration, :boolean, default: false, null: false add :max_characters, :integer, default: 5000, null: false add :max_upload_size, :integer, default: 50, null: false + add :public_key, :string + add :private_key, :string + + timestamps() end end end diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs index 265c22c..54ab8cb 100644 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -7,7 +7,6 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :email, :string add :password, :string add :is_moderator, :boolean, default: false, null: false - add :realname, :string add :bio, :string add :location, :string diff --git a/priv/repo/migrations/20250604083506_create_notes.exs b/priv/repo/migrations/20250604083506_create_notes.exs new file mode 100644 index 0000000..61ce44f --- /dev/null +++ b/priv/repo/migrations/20250604083506_create_notes.exs @@ -0,0 +1,18 @@ +defmodule Nulla.Repo.Migrations.CreateNotes do + use Ecto.Migration + + def change do + create table(:notes) do + add :content, :string + add :visibility, :string, default: "public" + add :sensitive, :boolean, default: false + add :language, :string + add :in_reply_to, :string + add :user_id, references(:users, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:notes, [:user_id]) + end +end diff --git a/priv/repo/migrations/20250606100445_create_bookmarks.exs b/priv/repo/migrations/20250606100445_create_bookmarks.exs new file mode 100644 index 0000000..810721e --- /dev/null +++ b/priv/repo/migrations/20250606100445_create_bookmarks.exs @@ -0,0 +1,14 @@ +defmodule Nulla.Repo.Migrations.CreateBookmarks do + use Ecto.Migration + + def change do + create table(:bookmarks) do + add :url, :string + add :user_id, references(:users, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:bookmarks, [:user_id]) + end +end diff --git a/priv/repo/migrations/20250606103230_create_notifications.exs b/priv/repo/migrations/20250606103230_create_notifications.exs new file mode 100644 index 0000000..85ec848 --- /dev/null +++ b/priv/repo/migrations/20250606103230_create_notifications.exs @@ -0,0 +1,18 @@ +defmodule Nulla.Repo.Migrations.CreateNotifications do + use Ecto.Migration + + def change do + create table(:notifications) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :actor_id, references(:users, on_delete: :nilify_all) + add :type, :string, null: false + add :data, :map + add :read, :boolean, default: false, null: false + + timestamps() + end + + create index(:notifications, [:user_id]) + create index(:notifications, [:actor_id]) + end +end diff --git a/priv/repo/migrations/20250606103527_create_moderations_logs.exs b/priv/repo/migrations/20250606103527_create_moderations_logs.exs new file mode 100644 index 0000000..252231f --- /dev/null +++ b/priv/repo/migrations/20250606103527_create_moderations_logs.exs @@ -0,0 +1,19 @@ +defmodule Nulla.Repo.Migrations.CreateModerationLogs do + use Ecto.Migration + + def change do + create table(:moderation_logs) do + add :moderator_id, references(:users, on_delete: :nilify_all), null: false + add :target_type, :string, null: false + add :target_id, :string, null: false + add :action, :string, null: false + add :reason, :text + add :metadata, :map + + timestamps() + end + + create index(:moderation_logs, [:moderator_id]) + create index(:moderation_logs, [:target_type, :target_id]) + end +end diff --git a/priv/repo/migrations/20250606103649_create_hashtags.exs b/priv/repo/migrations/20250606103649_create_hashtags.exs new file mode 100644 index 0000000..6a155cc --- /dev/null +++ b/priv/repo/migrations/20250606103649_create_hashtags.exs @@ -0,0 +1,14 @@ +defmodule Nulla.Repo.Migrations.CreateHashtags do + use Ecto.Migration + + def change do + create table(:hashtags) do + add :tag, :string, null: false + add :usage_count, :integer, default: 0, null: false + + timestamps() + end + + create unique_index(:hashtags, [:tag]) + end +end diff --git a/priv/repo/migrations/20250606103707_create_follows.exs b/priv/repo/migrations/20250606103707_create_follows.exs new file mode 100644 index 0000000..27f161f --- /dev/null +++ b/priv/repo/migrations/20250606103707_create_follows.exs @@ -0,0 +1,15 @@ +defmodule Nulla.Repo.Migrations.CreateFollows do + use Ecto.Migration + + def change do + create table(:follows) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :target_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:follows, [:user_id, :target_id]) + create index(:follows, [:target_id]) + end +end diff --git a/priv/repo/migrations/20250606131715_create_sessions.exs b/priv/repo/migrations/20250606131715_create_sessions.exs new file mode 100644 index 0000000..b11c9fc --- /dev/null +++ b/priv/repo/migrations/20250606131715_create_sessions.exs @@ -0,0 +1,17 @@ +defmodule Nulla.Repo.Migrations.CreateSessions do + use Ecto.Migration + + def change do + create table(:sessions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :string, null: false + add :user_agent, :string + add :ip, :string + + timestamps(type: :utc_datetime) + end + + create index(:sessions, [:user_id]) + create unique_index(:sessions, [:token]) + end +end diff --git a/priv/repo/migrations/20250606132108_create_media_attachments.exs b/priv/repo/migrations/20250606132108_create_media_attachments.exs new file mode 100644 index 0000000..0aef7bd --- /dev/null +++ b/priv/repo/migrations/20250606132108_create_media_attachments.exs @@ -0,0 +1,16 @@ +defmodule Nulla.Repo.Migrations.CreateMediaAttachments do + use Ecto.Migration + + def change do + create table(:media_attachments) do + add :note_id, references(:notes, on_delete: :delete_all), null: false + add :file, :string, null: false + add :mime_type, :string + add :description, :string + + timestamps(type: :utc_datetime) + end + + create index(:media_attachments, [:note_id]) + end +end From 1e36317c47a67113538bcf15ccd77806a0d150e2 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:43:20 +0200 Subject: [PATCH 035/144] Move CONTRIBUTING.md to README.md --- CONTRIBUTING.md | 9 --------- README.md | 10 ++++++++++ 2 files changed, 10 insertions(+), 9 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index feed8c0..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,9 +0,0 @@ -# Contributing - -## Patches via Email - -You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). Check out this [guide](https://git-send-email.io). - -## Your repository - -You fork this repository and make your changes in the feature branch, then I pull it. diff --git a/README.md b/README.md index 6985540..8771475 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,13 @@ filter keyword #tag user@example.com example.com * Show replies of all followed users * Show replies of this followed users + +## Contributing + +### Patches via Email + +You can create a patch with this command `git format-patch -M origin/main` and send it to this [email address](mailto:miraikumiko@disroot.org). Check out this [guide](https://git-send-email.io). + +### Your repository + +You fork this repository and make your changes in the feature branch, then I pull it. From ab12fb47a80fea41694c594f91494deed33d57f6 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:43:55 +0200 Subject: [PATCH 036/144] Add uploader.ex --- lib/nulla/uploader.ex | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 lib/nulla/uploader.ex diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex new file mode 100644 index 0000000..9704674 --- /dev/null +++ b/lib/nulla/uploader.ex @@ -0,0 +1,25 @@ +defmodule Nulla.Uploader do + def upload(%Plug.Upload{path: temp_path, filename: original_name}) do + {:ok, binary} = File.read(temp_path) + hash = :crypto.hash(:sha1, binary) |> Base.encode16(case: :lower) + file_type = Path.extname(original_name) + + segments = + hash + |> String.slice(0, 15) + |> String.codepoints() + |> Enum.chunk_every(3) + |> Enum.map(&Enum.join/1) + + filename = String.slice(hash, 15..-1) <> file_type + relative_path = Path.join(segments) <> "/" <> filename + + dest_path = Path.join(["priv/static/files", relative_path]) + + dest_path |> Path.dirname() |> File.mkdir_p!() + + File.write!(dest_path, binary) + + relative_path + end +end From 7d8cb334053a15b53bb2c24fef4ad197a6e5ba02 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 6 Jun 2025 16:54:05 +0200 Subject: [PATCH 037/144] Update LICENSE --- LICENSE | 233 +------------------------------------------------------- 1 file changed, 4 insertions(+), 229 deletions(-) diff --git a/LICENSE b/LICENSE index 0ea81f1..42fa4fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,232 +1,7 @@ -GNU GENERAL PUBLIC LICENSE -Version 3, 29 June 2007 +Copyright (c) 2025 Nulla -Copyright © 2007 Free Software Foundation, Inc. +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -Preamble - -The GNU General Public License is a free, copyleft license for software and other kinds of works. - -The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. - -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - -To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. - -For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. - -Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. - -For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. - -Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. - -Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. - -The precise terms and conditions for copying, distribution and modification follow. - -TERMS AND CONDITIONS - -0. Definitions. - -“This License” refers to version 3 of the GNU General Public License. - -“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. - -“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. - -To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. - -A “covered work” means either the unmodified Program or a work based on the Program. - -To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. - -To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. - -1. Source Code. -The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. - -A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. - -The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. - -The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -2. Basic Permissions. -All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. -No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. - -When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - -4. Conveying Verbatim Copies. -You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. -You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. - - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. - -A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - -6. Conveying Non-Source Forms. -You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. - -A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. - -“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. - -If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). - -The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - -7. Additional Terms. -“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. - -All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. - -8. Termination. -You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - -9. Acceptance Not Required for Having Copies. -You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. -Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. - -An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - -11. Patents. -A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. - -A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. - -In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. - -If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. - -A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. -If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - -13. Use with the GNU Affero General Public License. -Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. - -14. Revised Versions of this License. -The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. - -Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - -15. Disclaimer of Warranty. -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. -If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. - - nulla - Copyright (C) 2025 nulla - - This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - - nulla Copyright (C) 2025 nulla - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. - -You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . - -The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 4fb1e200f1fb792718c8f9613bbef6c33c5c6583 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 8 Jun 2025 10:58:48 +0200 Subject: [PATCH 038/144] mix format --- lib/nulla/activitypub.ex | 78 +++++++++++-------- lib/nulla/models/activity.ex | 21 +++++ lib/nulla/models/instance_settings.ex | 22 +++++- lib/nulla/models/note.ex | 6 +- lib/nulla/models/user.ex | 42 +++++++++- lib/nulla/uploader.ex | 2 +- lib/nulla_web/controllers/inbox_controller.ex | 25 ++++++ lib/nulla_web/controllers/user_controller.ex | 2 +- lib/nulla_web/controllers/user_html.ex | 23 ++++-- .../controllers/user_html/show.html.heex | 45 ++++++----- lib/nulla_web/router.ex | 7 +- .../20250607124601_create_activities.exs | 17 ++++ 12 files changed, 221 insertions(+), 69 deletions(-) create mode 100644 lib/nulla/models/activity.ex create mode 100644 lib/nulla_web/controllers/inbox_controller.ex create mode 100644 priv/repo/migrations/20250607124601_create_activities.exs diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 2bea870..66a439a 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -25,9 +25,9 @@ defmodule Nulla.ActivityPub do ] end - @spec ap_user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() - def ap_user(domain, user) do - Jason.OrderedObject.new([ + @spec user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() + def user(domain, user) do + Jason.OrderedObject.new( "@context": context(), id: "https://#{domain}/@#{user.username}", type: "Person", @@ -45,51 +45,52 @@ defmodule Nulla.ActivityPub do indexable: user.is_indexable, published: DateTime.to_iso8601(user.inserted_at), memorial: user.is_memorial, - publicKey: Jason.OrderedObject.new( - id: "https://#{domain}/@#{user.username}#main-key", - owner: "https://#{domain}/@#{user.username}", - publicKeyPem: user.public_key - ), - tag: Enum.map(user.tags, fn tag -> + publicKey: + Jason.OrderedObject.new( + id: "https://#{domain}/@#{user.username}#main-key", + owner: "https://#{domain}/@#{user.username}", + publicKeyPem: user.public_key + ), + tag: + Enum.map(user.tags, fn tag -> Jason.OrderedObject.new( type: "Hashtag", href: "https://#{domain}/tags/#{tag}", name: "##{tag}" ) end), - attachment: Enum.map(user.fields, fn {name, value} -> + attachment: + Enum.map(user.fields, fn {name, value} -> Jason.OrderedObject.new( type: "PropertyValue", name: name, value: value ) end), - endpoints: Jason.OrderedObject.new( - sharedInbox: "https://#{domain}/inbox" - ), - icon: Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.avatar), - url: "https://#{domain}#{user.avatar}" - ), - image: Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.banner), - url: "https://#{domain}#{user.banner}" - ), + endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox"), + icon: + Jason.OrderedObject.new( + type: "Image", + mediaType: MIME.from_path(user.avatar), + url: "https://#{domain}#{user.avatar}" + ), + image: + Jason.OrderedObject.new( + type: "Image", + mediaType: MIME.from_path(user.banner), + url: "https://#{domain}#{user.banner}" + ), "vcard:bday": user.birthday, "vcard:Address": user.location - ]) + ) end @spec note(String.t(), Nulla.Models.User.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() def note(domain, user, note) do - Jason.OrderedObject.new([ + Jason.OrderedObject.new( "@context": [ "https://www.w3.org/ns/activitystreams", - Jason.OrderedObject.new( - sensitive: "as:sensitive" - ) + Jason.OrderedObject.new(sensitive: "as:sensitive") ], id: "https://#{domain}/@#{user.username}/#{note.id}", type: "Note", @@ -106,16 +107,25 @@ defmodule Nulla.ActivityPub do ], sensetive: false, content: note.content, - contentMap: Jason.OrderedObject.new( - "#{note.language}": "

@rf@mastodonsocial.ru Вниманию новичкам!

Вам небольшое руководство о том, как импротировать пост, которого нет в вашей ленте.

" - ), + contentMap: Jason.OrderedObject.new("#{note.language}": note.content), attachment: [ Jason.OrderedObject.new( type: "Document", - mediaType: "video/mp4", - url: "https://mastodon.ml/system/media_attachments/files/000/040/494/original/8c06de179c11daea.mp4" + mediaType: "#{note.media_attachment.mime_type}", + url: "https://#{domain}/files/#{note.media_attachment.file}" ) ] - ]) + ) + end + + def activity(domain, action) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/activities/#{action.id}", + type: action.type, + actor: action.actor, + object: action.object, + to: action.to + ) end end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex new file mode 100644 index 0000000..bf0101e --- /dev/null +++ b/lib/nulla/models/activity.ex @@ -0,0 +1,21 @@ +defmodule Nulla.Models.Activity do + use Ecto.Schema + import Ecto.Changeset + + schema "activities" do + field :type, :string + field :actor, :string + field :object, :map + field :cc, {:array, :string}, default: [] + + timestamps() + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:type, :actor, :object, :to]) + |> validate_required([:type, :actor, :object]) + |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) + end +end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index 96280c1..5ed8860 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -18,8 +18,26 @@ defmodule Nulla.Models.InstanceSettings do @doc false def changeset(instance_settings, attrs) do instance_settings - |> cast(attrs, [:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key]) - |> validate_required([:name, :description, :domain, :registration, :max_characters, :max_upload_size, :public_key, :private_key]) + |> cast(attrs, [ + :name, + :description, + :domain, + :registration, + :max_characters, + :max_upload_size, + :public_key, + :private_key + ]) + |> validate_required([ + :name, + :description, + :domain, + :registration, + :max_characters, + :max_upload_size, + :public_key, + :private_key + ]) end def get_instance_settings!, do: Repo.one!(InstanceSettings) diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index f4d5960..965e642 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -7,7 +7,11 @@ defmodule Nulla.Models.Note do schema "notes" do field :content, :string - field :visibility, Ecto.Enum, values: [:public, :unlisted, :followers, :private], default: :public + + field :visibility, Ecto.Enum, + values: [:public, :unlisted, :followers, :private], + default: :public + field :sensitive, :boolean, default: false field :language, :string field :in_reply_to, :string diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index d350ad0..cd20f48 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -35,8 +35,46 @@ defmodule Nulla.Models.User do @doc false def changeset(user, attrs) do user - |> cast(attrs, [:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) - |> validate_required([:username, :email, :password, :is_moderator, :realname, :bio, :location, :birthday, :fields, :follow_approval, :is_bot, :is_discoverable, :is_indexable, :is_memorial, :private_key, :public_key, :avatar, :banner]) + |> cast(attrs, [ + :username, + :email, + :password, + :is_moderator, + :realname, + :bio, + :location, + :birthday, + :fields, + :follow_approval, + :is_bot, + :is_discoverable, + :is_indexable, + :is_memorial, + :private_key, + :public_key, + :avatar, + :banner + ]) + |> validate_required([ + :username, + :email, + :password, + :is_moderator, + :realname, + :bio, + :location, + :birthday, + :fields, + :follow_approval, + :is_bot, + :is_discoverable, + :is_indexable, + :is_memorial, + :private_key, + :public_key, + :avatar, + :banner + ]) end def get_user_by_username!(username), do: Repo.get_by!(User, username: username) diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex index 9704674..2abd925 100644 --- a/lib/nulla/uploader.ex +++ b/lib/nulla/uploader.ex @@ -11,7 +11,7 @@ defmodule Nulla.Uploader do |> Enum.chunk_every(3) |> Enum.map(&Enum.join/1) - filename = String.slice(hash, 15..-1) <> file_type + filename = String.slice(hash, 15..-1//1) <> file_type relative_path = Path.join(segments) <> "/" <> filename dest_path = Path.join(["priv/static/files", relative_path]) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex new file mode 100644 index 0000000..e4ed4c4 --- /dev/null +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.InboxController do + use NullaWeb, :controller + + def receive(conn, %{"type" => "Follow"} = activity) do + # Check signature + # Verify actor and object + # Save follow to db + # Send Accept or Reject + json(conn, %{"status" => "Follow received"}) + end + + def receive(conn, %{"type" => "Like"} = activity) do + # Process Like + json(conn, %{"status" => "Like received"}) + end + + def receive(conn, %{"type" => "Create"} = activity) do + # Create object and save + json(conn, %{"status" => "Object created"}) + end + + def receive(conn, _params) do + json(conn, %{"status" => "Unhandled type"}) + end +end diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 0f078e0..4c93d99 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -15,7 +15,7 @@ defmodule NullaWeb.UserController do if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.ap_user(domain, user)) + |> json(ActivityPub.user(domain, user)) else render(conn, :show, domain: domain, user: user, notes: notes, layout: false) end diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/controllers/user_html.ex index fe6103e..2f1ec2f 100644 --- a/lib/nulla_web/controllers/user_html.ex +++ b/lib/nulla_web/controllers/user_html.ex @@ -25,12 +25,19 @@ defmodule NullaWeb.UserHTML do relative = cond do - diff == 0 -> "today" - diff == 1 -> "1 day ago" - diff < 30 -> "#{diff} days ago" + diff == 0 -> + "today" + + diff == 1 -> + "1 day ago" + + diff < 30 -> + "#{diff} days ago" + diff < 365 -> months = Timex.diff(now, date, :months) if months == 1, do: "1 month ago", else: "#{months} months ago" + true -> years = Timex.diff(now, date, :years) if years == 1, do: "1 year ago", else: "#{years} years ago" @@ -46,7 +53,7 @@ defmodule NullaWeb.UserHTML do def format_note_datetime_diff(datetime) do now = Timex.now() diff = Timex.diff(now, datetime, :seconds) - + cond do diff < 60 -> "now" @@ -59,15 +66,15 @@ defmodule NullaWeb.UserHTML do hours = div(diff, 3600) "#{hours}h ago" - diff < 518400 -> + diff < 518_400 -> days = div(diff, 86400) "#{days}d ago" - diff < 2419200 -> - weeks = div(diff, 604800) + diff < 2_419_200 -> + weeks = div(diff, 604_800) "#{weeks}w ago" - diff < 28512000 -> + diff < 28_512_000 -> months = Timex.diff(now, datetime, :months) "#{months}mo ago" diff --git a/lib/nulla_web/controllers/user_html/show.html.heex b/lib/nulla_web/controllers/user_html/show.html.heex index 9ce9f87..9184f8b 100644 --- a/lib/nulla_web/controllers/user_html/show.html.heex +++ b/lib/nulla_web/controllers/user_html/show.html.heex @@ -1,8 +1,14 @@
- +
- +
@@ -12,8 +18,13 @@
- - + +
@@ -27,28 +38,28 @@
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= @user.location %>
+
{@user.location}
<% end %> <%= if @user.birthday do %>
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= format_birthdate(@user.birthday) %>
+
{format_birthdate(@user.birthday)}
<% end %>
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
-
<%= format_registration_date(@user.inserted_at) %>
+
{format_registration_date(@user.inserted_at)}
<%= if @user.fields do %>
<%= for {key, value} <- @user.fields do %> -
<%= key %>
+
{key}
<%= if Regex.match?(~r{://}, value) do %> - <%= Regex.replace(~r{^\w+://}, value, "") %> + {Regex.replace(~r{^\w+://}, value, "")} <% else %> - <%= value %> + {value} <% end %>
<% end %> @@ -70,15 +81,15 @@ <%= for note <- @notes do %>
- +
- <%= @user.realname %> + {@user.realname} - @<%= @user.username %>@<%= @domain %> + @{@user.username}@{@domain}
@@ -92,11 +103,11 @@ <% :private -> %> <.icon name="hero-at-symbol" class="h-5 w-5" /> <% end %> - <%= format_note_datetime_diff(note.inserted_at) %> + {format_note_datetime_diff(note.inserted_at)}
-

<%= note.content %>

+

{note.content}

@@ -110,8 +121,6 @@
-
- -
+
diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 4f956cd..5af267b 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -11,13 +11,16 @@ defmodule NullaWeb.Router do end pipeline :api do - plug :accepts, ["json"] + plug :accepts, ["activity+json", "ld+json"] + + post "/inbox", InboxController, :receive + post "/@:username/inbox", InboxController, :receive + get "/@:username/outbox", OutboxController, :index end scope "/", NullaWeb do pipe_through :browser - get "/", PageController, :home get "/@:username", UserController, :show resources "/users", UserController end diff --git a/priv/repo/migrations/20250607124601_create_activities.exs b/priv/repo/migrations/20250607124601_create_activities.exs new file mode 100644 index 0000000..0ed3cf2 --- /dev/null +++ b/priv/repo/migrations/20250607124601_create_activities.exs @@ -0,0 +1,17 @@ +defmodule Nulla.Repo.Migrations.CreateActivities do + use Ecto.Migration + + def change do + create table(:activities) do + add :type, :string, null: false + add :actor, :string, null: false + add :object, :map, null: false + add :to, {:array, :string}, default: [] + + timestamps() + end + + create index(:activities, [:actor]) + create index(:activities, [:type]) + end +end From cd1e5887c5d3e0234bb3e58d91d76fc7d13f98aa Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 8 Jun 2025 13:32:58 +0200 Subject: [PATCH 039/144] Move templates to components --- .../user_html.ex => components/templates.ex} | 16 +++++++++++++++- .../templates/note}/edit.html.heex | 0 .../templates/note}/index.html.heex | 0 .../templates/note}/new.html.heex | 0 .../templates/note}/note_form.html.heex | 0 .../templates/note}/show.html.heex | 0 .../templates/user}/edit.html.heex | 0 .../templates/user}/index.html.heex | 0 .../templates/user}/new.html.heex | 0 .../templates/user}/show.html.heex | 0 .../templates/user}/user_form.html.heex | 0 lib/nulla_web/controllers/note_html.ex | 13 ------------- 12 files changed, 15 insertions(+), 14 deletions(-) rename lib/nulla_web/{controllers/user_html.ex => components/templates.ex} (86%) rename lib/nulla_web/{controllers/note_html => components/templates/note}/edit.html.heex (100%) rename lib/nulla_web/{controllers/note_html => components/templates/note}/index.html.heex (100%) rename lib/nulla_web/{controllers/note_html => components/templates/note}/new.html.heex (100%) rename lib/nulla_web/{controllers/note_html => components/templates/note}/note_form.html.heex (100%) rename lib/nulla_web/{controllers/note_html => components/templates/note}/show.html.heex (100%) rename lib/nulla_web/{controllers/user_html => components/templates/user}/edit.html.heex (100%) rename lib/nulla_web/{controllers/user_html => components/templates/user}/index.html.heex (100%) rename lib/nulla_web/{controllers/user_html => components/templates/user}/new.html.heex (100%) rename lib/nulla_web/{controllers/user_html => components/templates/user}/show.html.heex (100%) rename lib/nulla_web/{controllers/user_html => components/templates/user}/user_form.html.heex (100%) delete mode 100644 lib/nulla_web/controllers/note_html.ex diff --git a/lib/nulla_web/controllers/user_html.ex b/lib/nulla_web/components/templates.ex similarity index 86% rename from lib/nulla_web/controllers/user_html.ex rename to lib/nulla_web/components/templates.ex index 2f1ec2f..87fc303 100644 --- a/lib/nulla_web/controllers/user_html.ex +++ b/lib/nulla_web/components/templates.ex @@ -1,7 +1,7 @@ defmodule NullaWeb.UserHTML do use NullaWeb, :html - embed_templates "user_html/*" + embed_templates "templates/user/*" @doc """ Renders a user form. @@ -84,3 +84,17 @@ defmodule NullaWeb.UserHTML do end end end + +defmodule NullaWeb.NoteHTML do + use NullaWeb, :html + + embed_templates "templates/note/*" + + @doc """ + Renders a note form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def note_form(assigns) +end diff --git a/lib/nulla_web/controllers/note_html/edit.html.heex b/lib/nulla_web/components/templates/note/edit.html.heex similarity index 100% rename from lib/nulla_web/controllers/note_html/edit.html.heex rename to lib/nulla_web/components/templates/note/edit.html.heex diff --git a/lib/nulla_web/controllers/note_html/index.html.heex b/lib/nulla_web/components/templates/note/index.html.heex similarity index 100% rename from lib/nulla_web/controllers/note_html/index.html.heex rename to lib/nulla_web/components/templates/note/index.html.heex diff --git a/lib/nulla_web/controllers/note_html/new.html.heex b/lib/nulla_web/components/templates/note/new.html.heex similarity index 100% rename from lib/nulla_web/controllers/note_html/new.html.heex rename to lib/nulla_web/components/templates/note/new.html.heex diff --git a/lib/nulla_web/controllers/note_html/note_form.html.heex b/lib/nulla_web/components/templates/note/note_form.html.heex similarity index 100% rename from lib/nulla_web/controllers/note_html/note_form.html.heex rename to lib/nulla_web/components/templates/note/note_form.html.heex diff --git a/lib/nulla_web/controllers/note_html/show.html.heex b/lib/nulla_web/components/templates/note/show.html.heex similarity index 100% rename from lib/nulla_web/controllers/note_html/show.html.heex rename to lib/nulla_web/components/templates/note/show.html.heex diff --git a/lib/nulla_web/controllers/user_html/edit.html.heex b/lib/nulla_web/components/templates/user/edit.html.heex similarity index 100% rename from lib/nulla_web/controllers/user_html/edit.html.heex rename to lib/nulla_web/components/templates/user/edit.html.heex diff --git a/lib/nulla_web/controllers/user_html/index.html.heex b/lib/nulla_web/components/templates/user/index.html.heex similarity index 100% rename from lib/nulla_web/controllers/user_html/index.html.heex rename to lib/nulla_web/components/templates/user/index.html.heex diff --git a/lib/nulla_web/controllers/user_html/new.html.heex b/lib/nulla_web/components/templates/user/new.html.heex similarity index 100% rename from lib/nulla_web/controllers/user_html/new.html.heex rename to lib/nulla_web/components/templates/user/new.html.heex diff --git a/lib/nulla_web/controllers/user_html/show.html.heex b/lib/nulla_web/components/templates/user/show.html.heex similarity index 100% rename from lib/nulla_web/controllers/user_html/show.html.heex rename to lib/nulla_web/components/templates/user/show.html.heex diff --git a/lib/nulla_web/controllers/user_html/user_form.html.heex b/lib/nulla_web/components/templates/user/user_form.html.heex similarity index 100% rename from lib/nulla_web/controllers/user_html/user_form.html.heex rename to lib/nulla_web/components/templates/user/user_form.html.heex diff --git a/lib/nulla_web/controllers/note_html.ex b/lib/nulla_web/controllers/note_html.ex deleted file mode 100644 index 447c9ab..0000000 --- a/lib/nulla_web/controllers/note_html.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule NullaWeb.NoteHTML do - use NullaWeb, :html - - embed_templates "note_html/*" - - @doc """ - Renders a note form. - """ - attr :changeset, Ecto.Changeset, required: true - attr :action, :string, required: true - - def note_form(assigns) -end From f8bedff9133b6fae5327d6257d44be6c98f6d191 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 8 Jun 2025 13:44:23 +0200 Subject: [PATCH 040/144] Update --- lib/nulla/activitypub.ex | 49 ++++++++++++-------- lib/nulla/models/note.ex | 2 + lib/nulla_web/controllers/note_controller.ex | 33 +++++++++++-- lib/nulla_web/router.ex | 2 +- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 66a439a..2be7a2e 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -85,47 +85,58 @@ defmodule Nulla.ActivityPub do ) end - @spec note(String.t(), Nulla.Models.User.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() - def note(domain, user, note) do + @spec note(String.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + def note(domain, note) do + attachment = + case note.media_attachments do + [] -> [] + attachments -> + [ + attachment: + Enum.map(attachments, fn att -> + Jason.OrderedObject.new( + type: "Document", + mediaType: att.mime_type, + url: "https://#{domain}/files/#{att.file}" + ) + end) + ] + end + Jason.OrderedObject.new( "@context": [ "https://www.w3.org/ns/activitystreams", Jason.OrderedObject.new(sensitive: "as:sensitive") ], - id: "https://#{domain}/@#{user.username}/#{note.id}", + id: "https://#{domain}/@#{note.user.username}/#{note.id}", type: "Note", summary: nil, inReplyTo: nil, published: note.inserted_at, - url: "https://#{domain}/@#{user.username}/#{note.id}", - attributedTo: "https://#{domain}/@#{user.username}", + url: "https://#{domain}/@#{note.user.username}/#{note.id}", + attributedTo: "https://#{domain}/@#{note.user.username}", to: [ "https://www.w3.org/ns/activitystreams#Public" ], cc: [ - "https://#{domain}/@#{user.username}/followers" + "https://#{domain}/@#{note.user.username}/followers" ], sensetive: false, content: note.content, contentMap: Jason.OrderedObject.new("#{note.language}": note.content), - attachment: [ - Jason.OrderedObject.new( - type: "Document", - mediaType: "#{note.media_attachment.mime_type}", - url: "https://#{domain}/files/#{note.media_attachment.file}" - ) - ] + attachment: attachment ) end - def activity(domain, action) do + @spec activity(String.t(), Nulla.Models.Activity.t()) :: Jason.OrderedObject.t() + def activity(domain, activity) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/activities/#{action.id}", - type: action.type, - actor: action.actor, - object: action.object, - to: action.to + id: "https://#{domain}/activities/#{activity.id}", + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to ) end end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 965e642..57a07e6 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -29,5 +29,7 @@ defmodule Nulla.Models.Note do |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) end + def get_note!(id), do: Repo.get!(Note, id) + def get_all_notes!(user_id), do: Repo.all(from n in Note, where: n.user_id == ^user_id) end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index 814a1dd..ae4516a 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -1,8 +1,9 @@ defmodule NullaWeb.NoteController do use NullaWeb, :controller - - alias Nulla.Notes + alias Nulla.Repo alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings + alias Nulla.ActivityPub def index(conn, _params) do notes = Notes.list_notes() @@ -26,11 +27,33 @@ defmodule NullaWeb.NoteController do end end - def show(conn, %{"id" => id}) do - note = Notes.get_note!(id) - render(conn, :show, note: note) + def show(conn, %{"username" => username, "note_id" => note_id}) do + accept = List.first(get_req_header(conn, "accept")) + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + note = Note.get_note!(note_id) |> Repo.preload([:user, :media_attachments]) + + if username != note.user.username do + conn + |> put_status(:not_found) + |> json(%{error: "Note not found"}) + |> halt() + end + + if accept in ["application/activity+json", "application/ld+json"] do + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.note(domain, note)) + else + render(conn, :show, domain: domain, note: note, layout: false) + end end + #def show(conn, %{"id" => id}) do + # note = Notes.get_note!(id) + # render(conn, :show, note: note) + #end + def edit(conn, %{"id" => id}) do note = Notes.get_note!(id) changeset = Notes.change_note(note) diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 5af267b..783534f 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -22,7 +22,7 @@ defmodule NullaWeb.Router do pipe_through :browser get "/@:username", UserController, :show - resources "/users", UserController + get "/@:username/:note_id", NoteController, :show end # Other scopes may use custom stacks. From 0b8888193460c1eb5ff103cf601640e618881936 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 11 Jun 2025 15:03:21 +0200 Subject: [PATCH 041/144] Add following and followers --- lib/nulla/activitypub.ex | 86 ++++++++++++++++++ lib/nulla/models/instance_settings.ex | 3 + lib/nulla/utils.ex | 87 +++++++++++++++++++ .../components/templates/user/show.html.heex | 6 +- .../controllers/follow_controller.ex | 65 ++++++++++++++ lib/nulla_web/controllers/user_controller.ex | 15 +++- lib/nulla_web/router.ex | 2 + ...0250527054942_create_instance_settings.exs | 1 + 8 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 lib/nulla/utils.ex create mode 100644 lib/nulla_web/controllers/follow_controller.ex diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 2be7a2e..1af33ae 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -139,4 +139,90 @@ defmodule Nulla.ActivityPub do to: activity.to ) end + + @spec following(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, user, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/following", + type: "OrderedCollection", + totalItems: total, + first: "https://#{domain}/@#{user.username}/following?page=1" + ) + end + + @spec following(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, user, total, following_list, page, offset) when is_integer(page) and page > 0 do + data = [ + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/following?page=#{page}", + type: "OrderedCollectionPage", + totalItems: total, + next: "https://#{domain}/@#{user.username}/following?page=#{page + 1}", + prev: "https://#{domain}/@#{user.username}/following?page=#{page - 1}", + partOf: "https://#{domain}/@#{user.username}/following", + orderedItems: following_list + ] + + data = + if page <= 1 do + Keyword.delete(data, :prev) + else + data + end + + data = + if page * offset > total do + data + |> Keyword.delete(:next) + |> Keyword.delete(:prev) + else + data + end + + Jason.OrderedObject.new(data) + end + + @spec followers(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, user, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/followers", + type: "OrderedCollection", + totalItems: total, + first: "https://#{domain}/@#{user.username}/followers?page=1" + ) + end + + @spec followers(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, user, total, followers_list, page, offset) when is_integer(page) and page > 0 do + data = [ + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{user.username}/followers?page=#{page}", + type: "OrderedCollectionPage", + totalItems: total, + next: "https://#{domain}/@#{user.username}/followers?page=#{page + 1}", + prev: "https://#{domain}/@#{user.username}/followers?page=#{page - 1}", + partOf: "https://#{domain}/@#{user.username}/followers", + orderedItems: followers_list + ] + + data = + if page <= 1 do + Keyword.delete(data, :prev) + else + data + end + + data = + if page * offset > total do + data + |> Keyword.delete(:next) + |> Keyword.delete(:prev) + else + data + end + + Jason.OrderedObject.new(data) + end end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index 5ed8860..de903c7 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -11,6 +11,7 @@ defmodule Nulla.Models.InstanceSettings do field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 + field :offset, :integer, default: 100 field :public_key, :string field :private_key, :string end @@ -25,6 +26,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, + :offset, :public_key, :private_key ]) @@ -35,6 +37,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, + :offset, :public_key, :private_key ]) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex new file mode 100644 index 0000000..cf01214 --- /dev/null +++ b/lib/nulla/utils.ex @@ -0,0 +1,87 @@ +defmodule Nulla.Utils do + import Ecto.Query + alias Nulla.Repo + alias Nulla.Models.User + alias Nulla.Models.Follow + alias Nulla.Models.InstanceSettings + + def count_following_by_username!(username) do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + count = + Follow + |> where([f], f.user_id == ^user_id) + |> select([f], count(f.id)) + |> Repo.one() + + count + end + end + + def get_following_users_by_username!(username, page) when is_integer(page) and page > 0 do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + instance_settings = InstanceSettings.get_instance_settings!() + per_page = instance_settings.offset + offset = (page - 1) * per_page + + query = + from [f, u] in + from(f in Follow, + join: u in User, on: u.id == f.target_id, + where: f.user_id == ^user_id, + order_by: [asc: u.inserted_at], + offset: ^offset, + limit: ^per_page, + select: u + ) + + users = Repo.all(query) + + users + end + end + + def count_followers_by_username!(username) do + case Repo.get_by(User, username: username) do + nil -> + 0 + + %User{id: user_id} -> + from(f in Follow, where: f.target_id == ^user_id) + |> select([f], count(f.id)) + |> Repo.one() + end + end + + def get_followers_by_username!(username, page) when is_integer(page) and page > 0 do + case Repo.get_by(User, username: username) do + nil -> + {:error, :user_not_found} + + %User{id: user_id} -> + instance_settings = InstanceSettings.get_instance_settings!() + per_page = instance_settings.offset + offset = (page - 1) * per_page + + query = + from f in Follow, + where: f.target_id == ^user_id, + join: u in User, on: u.id == f.user_id, + order_by: [asc: u.inserted_at], + offset: ^offset, + limit: ^per_page, + select: u + + users = Repo.all(query) + + users + end + end +end diff --git a/lib/nulla_web/components/templates/user/show.html.heex b/lib/nulla_web/components/templates/user/show.html.heex index 9184f8b..5a41a2f 100644 --- a/lib/nulla_web/components/templates/user/show.html.heex +++ b/lib/nulla_web/components/templates/user/show.html.heex @@ -66,9 +66,9 @@ <% end %>
diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex new file mode 100644 index 0000000..ff82b6a --- /dev/null +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -0,0 +1,65 @@ +defmodule NullaWeb.FollowController do + use NullaWeb, :controller + alias Nulla.Models.User + alias Nulla.Models.InstanceSettings + alias Nulla.ActivityPub + alias Nulla.Utils + + def following(conn, %{"username" => username, "page" => page_param}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + offset = instance_settings.offset + user = User.get_user_by_username!(username) + total = Utils.count_following_by_username!(user.username) + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + following_list = Utils.get_following_users_by_username!(user.username, page) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) + end + + def following(conn, %{"username" => username}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Utils.count_following_by_username!(user.username) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total)) + end + + def followers(conn, %{"username" => username, "page" => page_param}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + offset = instance_settings.offset + user = User.get_user_by_username!(username) + total = Utils.count_followers_by_username!(user.username) + page = + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + followers_list = Utils.get_followers_by_username!(user.username, page) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) + end + + def followers(conn, %{"username" => username}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Utils.count_followers_by_username!(user.username) + + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total)) + end +end diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 4c93d99..45ebd86 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -4,6 +4,7 @@ defmodule NullaWeb.UserController do alias Nulla.Models.Note alias Nulla.Models.InstanceSettings alias Nulla.ActivityPub + alias Nulla.Utils def show(conn, %{"username" => username}) do accept = List.first(get_req_header(conn, "accept")) @@ -17,7 +18,19 @@ defmodule NullaWeb.UserController do |> put_resp_content_type("application/activity+json") |> json(ActivityPub.user(domain, user)) else - render(conn, :show, domain: domain, user: user, notes: notes, layout: false) + following = Utils.count_following_by_username!(user.username) + followers = Utils.count_followers_by_username!(user.username) + + render( + conn, + :show, + domain: domain, + user: user, + notes: notes, + following: following, + followers: followers, + layout: false + ) end end end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 783534f..04268ed 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -22,6 +22,8 @@ defmodule NullaWeb.Router do pipe_through :browser get "/@:username", UserController, :show + get "/@:username/following", FollowController, :following + get "/@:username/followers", FollowController, :followers get "/@:username/:note_id", NoteController, :show end diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index b316d05..cc3aad5 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -9,6 +9,7 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do add :registration, :boolean, default: false, null: false add :max_characters, :integer, default: 5000, null: false add :max_upload_size, :integer, default: 50, null: false + add :offset, :integer, default: 100, null: false add :public_key, :string add :private_key, :string From da4cdc612a66d80ed26ef331a7732f4e48a8dede Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 11 Jun 2025 18:52:32 +0200 Subject: [PATCH 042/144] Update activitypub.ex --- lib/nulla/activitypub.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 1af33ae..081678f 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -72,13 +72,13 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new( type: "Image", mediaType: MIME.from_path(user.avatar), - url: "https://#{domain}#{user.avatar}" + url: "https://#{domain}/files/#{user.avatar}" ), image: Jason.OrderedObject.new( type: "Image", mediaType: MIME.from_path(user.banner), - url: "https://#{domain}#{user.banner}" + url: "https://#{domain}/files/#{user.banner}" ), "vcard:bday": user.birthday, "vcard:Address": user.location From f3c35e8e558477792846d448573a893588b0263a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 11:10:24 +0200 Subject: [PATCH 043/144] Add keys to instance_settings --- lib/nulla/key_gen.ex | 16 +++++++++ lib/nulla/models/instance_settings.ex | 6 ++-- lib/nulla/utils.ex | 4 +-- ...0250527054942_create_instance_settings.exs | 34 ++++++++++++++++--- .../20250604083506_create_notes.exs | 2 +- 5 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 lib/nulla/key_gen.ex diff --git a/lib/nulla/key_gen.ex b/lib/nulla/key_gen.ex new file mode 100644 index 0000000..48740c8 --- /dev/null +++ b/lib/nulla/key_gen.ex @@ -0,0 +1,16 @@ +defmodule Nulla.KeyGen do + def generate_keys do + rsa_key = :public_key.generate_key({:rsa, 2048, 65537}) + + {:RSAPrivateKey, :"two-prime", n, e, _d, _p, _q, _dp, _dq, _qi, _other} = rsa_key + public_key = {:RSAPublicKey, n, e} + + private_entry = {:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} + public_entry = {:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} + + private_pem = :public_key.pem_encode([private_entry]) + public_pem = :public_key.pem_encode([public_entry]) + + {public_pem, private_pem} + end +end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index de903c7..0a2f38f 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -11,7 +11,7 @@ defmodule Nulla.Models.InstanceSettings do field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 - field :offset, :integer, default: 100 + field :api_offset, :integer, default: 100 field :public_key, :string field :private_key, :string end @@ -26,7 +26,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, - :offset, + :api_offset, :public_key, :private_key ]) @@ -37,7 +37,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, - :offset, + :api_offset, :public_key, :private_key ]) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index cf01214..9335dd3 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -28,7 +28,7 @@ defmodule Nulla.Utils do %User{id: user_id} -> instance_settings = InstanceSettings.get_instance_settings!() - per_page = instance_settings.offset + per_page = instance_settings.api_offset offset = (page - 1) * per_page query = @@ -67,7 +67,7 @@ defmodule Nulla.Utils do %User{id: user_id} -> instance_settings = InstanceSettings.get_instance_settings!() - per_page = instance_settings.offset + per_page = instance_settings.api_offset offset = (page - 1) * per_page query = diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index cc3aad5..45fd5f9 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -4,16 +4,42 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do def change do create table(:instance_settings) do add :name, :string, default: "Nulla", null: false - add :description, :string, default: "Freedom Social Network", null: false + add :description, :text, default: "Freedom Social Network", null: false add :domain, :string, default: "localhost", null: false add :registration, :boolean, default: false, null: false add :max_characters, :integer, default: 5000, null: false add :max_upload_size, :integer, default: 50, null: false - add :offset, :integer, default: 100, null: false - add :public_key, :string - add :private_key, :string + add :api_offset, :integer, default: 100, null: false + add :public_key, :text + add :private_key, :text timestamps() end + + flush() + + execute(fn -> + {public_key, private_key} = Nulla.KeyGen.generate_keys() + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + domain = + Application.get_env(:nulla, NullaWeb.Endpoint, []) + |> Keyword.get(:url, []) + |> Keyword.get(:host, "localhost") + esc = fn str -> "'#{String.replace(str, "'", "''")}'" end + sql = """ + INSERT INTO instance_settings ( + name, description, domain, registration, + max_characters, max_upload_size, api_offset, + public_key, private_key, inserted_at, updated_at + ) VALUES ( + 'Nulla', 'Freedom Social Network', '#{domain}', false, + 5000, 50, 100, + #{esc.(public_key)}, #{esc.(private_key)}, + '#{now}', '#{now}' + ) + """ + + Ecto.Adapters.SQL.query!(Nulla.Repo, sql, []) + end) end end diff --git a/priv/repo/migrations/20250604083506_create_notes.exs b/priv/repo/migrations/20250604083506_create_notes.exs index 61ce44f..e14f519 100644 --- a/priv/repo/migrations/20250604083506_create_notes.exs +++ b/priv/repo/migrations/20250604083506_create_notes.exs @@ -3,7 +3,7 @@ defmodule Nulla.Repo.Migrations.CreateNotes do def change do create table(:notes) do - add :content, :string + add :content, :text add :visibility, :string, default: "public" add :sensitive, :boolean, default: false add :language, :string From 385afb93088fbd55925b6d3a0fb7207d5fce6b4f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 13:31:59 +0200 Subject: [PATCH 044/144] mix format --- lib/nulla/activitypub.ex | 54 ++++++++++++------- lib/nulla/key_gen.ex | 9 ++-- lib/nulla/utils.ex | 11 ++-- .../controllers/follow_controller.ex | 36 +++++++------ lib/nulla_web/controllers/note_controller.ex | 4 +- ...0250527054942_create_instance_settings.exs | 9 ++-- 6 files changed, 77 insertions(+), 46 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 081678f..e3c93f8 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -88,20 +88,22 @@ defmodule Nulla.ActivityPub do @spec note(String.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() def note(domain, note) do attachment = - case note.media_attachments do - [] -> [] - attachments -> - [ - attachment: - Enum.map(attachments, fn att -> - Jason.OrderedObject.new( - type: "Document", - mediaType: att.mime_type, - url: "https://#{domain}/files/#{att.file}" - ) - end) - ] - end + case note.media_attachments do + [] -> + [] + + attachments -> + [ + attachment: + Enum.map(attachments, fn att -> + Jason.OrderedObject.new( + type: "Document", + mediaType: att.mime_type, + url: "https://#{domain}/files/#{att.file}" + ) + end) + ] + end Jason.OrderedObject.new( "@context": [ @@ -151,8 +153,16 @@ defmodule Nulla.ActivityPub do ) end - @spec following(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() - def following(domain, user, total, following_list, page, offset) when is_integer(page) and page > 0 do + @spec following( + String.t(), + Nulla.Models.User.t(), + Integer.t(), + List.t(), + Integer.t(), + Integer.t() + ) :: Jason.OrderedObject.t() + def following(domain, user, total, following_list, page, offset) + when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", id: "https://#{domain}/@#{user.username}/following?page=#{page}", @@ -194,8 +204,16 @@ defmodule Nulla.ActivityPub do ) end - @spec followers(String.t(), Nulla.Models.User.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: Jason.OrderedObject.t() - def followers(domain, user, total, followers_list, page, offset) when is_integer(page) and page > 0 do + @spec followers( + String.t(), + Nulla.Models.User.t(), + Integer.t(), + List.t(), + Integer.t(), + Integer.t() + ) :: Jason.OrderedObject.t() + def followers(domain, user, total, followers_list, page, offset) + when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", id: "https://#{domain}/@#{user.username}/followers?page=#{page}", diff --git a/lib/nulla/key_gen.ex b/lib/nulla/key_gen.ex index 48740c8..c803920 100644 --- a/lib/nulla/key_gen.ex +++ b/lib/nulla/key_gen.ex @@ -5,11 +5,14 @@ defmodule Nulla.KeyGen do {:RSAPrivateKey, :"two-prime", n, e, _d, _p, _q, _dp, _dq, _qi, _other} = rsa_key public_key = {:RSAPublicKey, n, e} - private_entry = {:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} - public_entry = {:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} + private_entry = + {:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} + + public_entry = + {:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} private_pem = :public_key.pem_encode([private_entry]) - public_pem = :public_key.pem_encode([public_entry]) + public_pem = :public_key.pem_encode([public_entry]) {public_pem, private_pem} end diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index 9335dd3..80a66c3 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -32,15 +32,17 @@ defmodule Nulla.Utils do offset = (page - 1) * per_page query = - from [f, u] in - from(f in Follow, - join: u in User, on: u.id == f.target_id, + from( + [f, u] in from(f in Follow, + join: u in User, + on: u.id == f.target_id, where: f.user_id == ^user_id, order_by: [asc: u.inserted_at], offset: ^offset, limit: ^per_page, select: u ) + ) users = Repo.all(query) @@ -73,7 +75,8 @@ defmodule Nulla.Utils do query = from f in Follow, where: f.target_id == ^user_id, - join: u in User, on: u.id == f.user_id, + join: u in User, + on: u.id == f.user_id, order_by: [asc: u.inserted_at], offset: ^offset, limit: ^per_page, diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index ff82b6a..a53fed8 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -11,16 +11,18 @@ defmodule NullaWeb.FollowController do offset = instance_settings.offset user = User.get_user_by_username!(username) total = Utils.count_following_by_username!(user.username) + page = - case Integer.parse(page_param) do - {int, _} when int > 0 -> int - _ -> 1 - end + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + following_list = Utils.get_following_users_by_username!(user.username, page) conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) end def following(conn, %{"username" => username}) do @@ -30,8 +32,8 @@ defmodule NullaWeb.FollowController do total = Utils.count_following_by_username!(user.username) conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total)) + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.following(domain, user, total)) end def followers(conn, %{"username" => username, "page" => page_param}) do @@ -40,16 +42,18 @@ defmodule NullaWeb.FollowController do offset = instance_settings.offset user = User.get_user_by_username!(username) total = Utils.count_followers_by_username!(user.username) + page = - case Integer.parse(page_param) do - {int, _} when int > 0 -> int - _ -> 1 - end + case Integer.parse(page_param) do + {int, _} when int > 0 -> int + _ -> 1 + end + followers_list = Utils.get_followers_by_username!(user.username, page) conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) end def followers(conn, %{"username" => username}) do @@ -59,7 +63,7 @@ defmodule NullaWeb.FollowController do total = Utils.count_followers_by_username!(user.username) conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total)) + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.followers(domain, user, total)) end end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index ae4516a..4ad29c9 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -49,10 +49,10 @@ defmodule NullaWeb.NoteController do end end - #def show(conn, %{"id" => id}) do + # def show(conn, %{"id" => id}) do # note = Notes.get_note!(id) # render(conn, :show, note: note) - #end + # end def edit(conn, %{"id" => id}) do note = Notes.get_note!(id) diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index 45fd5f9..3f8be91 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -21,11 +21,14 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do execute(fn -> {public_key, private_key} = Nulla.KeyGen.generate_keys() now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + domain = - Application.get_env(:nulla, NullaWeb.Endpoint, []) - |> Keyword.get(:url, []) - |> Keyword.get(:host, "localhost") + Application.get_env(:nulla, NullaWeb.Endpoint, []) + |> Keyword.get(:url, []) + |> Keyword.get(:host, "localhost") + esc = fn str -> "'#{String.replace(str, "'", "''")}'" end + sql = """ INSERT INTO instance_settings ( name, description, domain, registration, From 7372e0fdc15da6071ba105de4ff1d303745f09eb Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 14:06:19 +0200 Subject: [PATCH 045/144] Update bio in users table --- priv/repo/migrations/20250530110822_create_users.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs index 54ab8cb..cc7ab33 100644 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -8,7 +8,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :password, :string add :is_moderator, :boolean, default: false, null: false add :realname, :string - add :bio, :string + add :bio, :text add :location, :string add :birthday, :date add :fields, :map From 76ec1d144f1f11b2e2463c0bbab99877c9f30407 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 14:13:13 +0200 Subject: [PATCH 046/144] Add favicon.ico --- priv/static/favicon.ico | Bin 152 -> 165031 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 7f372bfc21cdd8cb47585339d5fa4d9dd424402f..145cc2915017fdec6869d5f8c6012926e2669028 100644 GIT binary patch literal 165031 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%0AuXvAW7bO&iJBD6^#gn*TIe zT)#)tePa^uKL&bZ26%>{sK2KFLjL9FNcdgJJtQdf-{Jr8EUVVqKR$DeZ4O+txloNJ zNdxT;z3>oXDo`TFa~Dd%j`NicB}vT(1u_+eMmXYm2-W)GyyuX%I?nqoH0BGXQ1dYr z&M_-TEgr8EF${PYC|M_Bq<`0mP)m+KT7Lu~kL{r~t7`B%_3>DzxD)rECRTU*q4A=H zf**|u<52Kp%!_tZ@S`zd914DndC`svel#YG!?T$mt*!JFqD_^sRsxR+JPxj^yM2G$ zQ(2A98B4w5qpTNsv_ZmJ;k;HzE2Pb1uAPj4Zyuj-q3D^O=WhjyyE6TsnJC}+MrG6; z<9cu$pVko3m~+^T`W@uZLsb6_aKz)%yf#5OV05Q@?trfC?~1|SA2-A}4-0bmDy?!KjV3PGZ**_SOk1v1n}{J69Y63&jx;YEfSuu!Q;T5fbUwNe-;EtPo6#e z*wK4Djd60GZxQbn{H@e1`T}45YsCEcWh}jR*cbP$`QC${#(M(Z=f-US<{+Jg{CK+m z(9SVGn*={@@6`l9@CUFlcM$xz=Y}~G{0M&B*^ZgxW%S1{W9g8GHjcN&vuqst%j1ja zTk!a6C+>gwx_Otc#tpae+P+a(h97M#oyXHW!Q)uEcjc_E@iAWWv=BX@Jam3Mjn7j- zKK)*R{-F#`w6XLA?7KsSW9hYn9~o!-0F--BdC;Id+BEF6{q6l{FvUFG zzL3&wF5qK0cT#?ZVwA3I3m(VPYYRX8e4})Hez}`*XLT$+)GpIz!0k7^xC?H_31#ZyEVm0!yk0_4V_C6n!S~PLs~D~u<_zPUj7jNrnI9uzg9!8g0$jB&zbpD<0IF^!<6;iCkPxdt4ubs2fF=$SH z1-iq$)olCH_{q)9m9nz343X_TF3UFdyLe|o>t|OKRy1}{^Z7CAGa4O(4*@qS{QOQw z&DI|yE{u)|evDWtJtp`m<-%xB@MFYE=`q1iDHle2f*&JRN{{LIc>?<;`ZZ?b)VcQS zG4Nw{K0ZjQh^IQoPkYpPkO>S!oc(7ZKUzPrq7d&v*f*ao{FHj4iub??b5|$Ee=4r6 z_b~)No;efz2!1>>;Q4HA)gNtqIM5-+#o(Xi+T@?zj->~JE4-Hne!Ll0Cf{%&(5DjkT z0{qUg&zqAUUVC{yz#MIH4|y}*&&61}w%#GHO;{Ds)>aJ1{F-;L4q8$yY>oSg?#jyV zicU$R=cE)Gr#?&bl_2Dy^;wxCwr>xh5Uhtd*@C(A`Itkmu4l+$fMG%U+MN8u)Pka@ z?PMqr-bJ-}D60~ZK(|Wq)ZbtHXgs+M?IcS!jHUA|vOuks=$bCCoyg!#o0AWloESqF zus?EJR-N2^RUNOcv)N?{bD1QYEKlS)Jv=W3Pxru7;Qy&}h`>=E{qEN}`CbuFL1ccO z#m`0Y>~A8-QRP{6e^?g0u6*s_hjphaH`Y+LyIlIMR_Kr5r@XQ0?Mv_@_$kkT=OcAl ze~2Pbc`S#Gzkb_y(m4mAb6rizw|$fg<#SwXW*-Ymmc*~VeJM;UGYIf`KPbEOT9f<8 z&dF6ai@?Wy+!d5uTc!Kn$y%=uTwATozU>n(^PB$bY>L+*9Sdv)O4cF8qNEq~bO48| zWOMfU@f||@-vI2Yrh?b{zBn2>v;6H%m znn+skZ-S%%MiKl6kX#c<3;s=z6u>Bg{{WI}B5A?D36e}k;b)TGqPFwzZGH9eP10&Q znm%o_`$;s4pNWOx*=cRN{|z%+M|bf|6Kf(O>+vCcg3g&&D8g`{b49?q2%VDYfy*(M zqcs@ymb=1WvSq}xr{JJ^(2uY_f#7L z<4bo-b8M9M8GI3cgR_P~S%i5Wi{oiv!V&6?Z&F_61$yBbX%il|D_PHlW#G@v$v@_Q z$m?Xv!Tgimz%JnbD!|{&JIdFL%XbXuj5T~r&5>~CStW60%EGrV(?fuD06e8xWT@k& zN2r(fJ~{iVKQt=Z`A||Ndan_cR3m zzH{e~D+T}lVA1z91pmHs=Z`DTHvY9fvbUi>xHd}TEaIQX?bQ22UqU19{Bi6o;Xf6e z-vRUn`gnm$5jPkJ*jD%XZ_f9JymoUokm&_-5O)*M0tnbvm-#QE{S-%+JJ+h~tj_V{ zd48U80l)KwF{2FFI@L)`uMYJdCmS7 z`mgWo3IF3xZ-oBq^~BeG-}OuI?jeL<_AUIsfAqtZSF^tb|22&NO3laA*TraG z_#aohD5L+{d`zBObAcBqnU872x&Bz)=3hTw%avz$zcv1YdEC^i`IwLo&x0y6A5+h@ z(SF_KUnb1?#2mzrfKPID^FKd8ojrgSVf9BGR9a8G^ zxE;%Y?WMx4?($!D{u^|;GsdDToPTJO3#0;jdWhAQzRqUNB&$7xY&OrZ+YwJbMW^KV zC{k(4{IeYh{xL^aKmRb2npViaove~fMIkmSUT1~-;%_4IidCM#YiRMBO*zUgEkD6O zMz45|w4Q%+{=fnm6@@5dS?Ay$#PWYP;FG+@5{T|OZ9S)e9ZyH7CU5oK4UT6NS=O6b{yf!!Y z|JQ5$dJJ)2Y{TFKughK_|B3tUFt6vE9-{i0_y&U2k$VNCO82Z7>QuoTQ~ey&PWV=G7pxcmi&M;JH~$t3kN=Q_Z!OnP zO-&7{2qzFHEiE-9F)6h{fi?bId|!EYMRQEcpO1&<$wjup|M2&mng6`JJi~7}yME(Y z`?j;yre4`_6uHON3Rl-jLv5FCW3bYrEy!{jZ)= zZ}tWMf`9Ek+Z(;9D6Uhle7HXs`d<;ZX3h)#UF}-?E!tnb{s{lC>4g>w|D)FrU-yOo z(e%kR6#hrAQ^Nna>PNNtpS_R;#s>loq5lT@-x$141wI74S!Z-H;wJ!J$W8EXz(2{)K_wzBqzc=fIRyr=`e`e*<1~2D-^7@XJ!ZY;t*!_<0Lh-k`0;T`;!Tk(?Wx(T9 zCP3>7Xy=Alx};=2X0*EYr#Evm_473SxnEiaE)(>hPST5%Pn)x_X66QS^|Swgcwb_` z^uiM~S>-)|{PQ>!%Yes8OoAHVyH0HZbv|YuJY@k;h!t6D0xTu#kn(z?i}BuMBdoa? zy89U2)E1obc&N#G+JX>YiluHPG6;bG(Z&hA2-Wv^)%lqG_Bv+ht8?`EJ@p!alKG+9 zdZQuy-tgb}ZeNQey4#C-mX3G(llRJ|Wx+ghpru8T|F+@f2t}Qv&-3(ot$^wUm%-{R z19aE9-l&6r$RMEW9aolDv)9}0vIMl0W#w;F71NP<1qW`kc%Ct@=}?cMw^U^Q{%OtI9L&SZLk!tUg@!ck`z{wX6@ORq=8Nzm{4E$gYBi)`g0#ea>xkj`(+<@Y$fT`?EMm5 z@|S+<(^1)Ua_i%MD4USuTHdR|{jSJ0f^!by|DAacTaa{7Pf3O2clH0p2!S>h*$?|! z0BEXfAj+cU7=u#R?#;p&V81{-OQ?QndS3z|fA2Y~OIHZ_*ClCsUl$>N?>VbWR|xsn zC24wJ7a@P|Ijc)o2>I6~X?kB5A%E{Vt4mi1`PU_BdS4eIfA2Y~OIHZ_*ClCsUl$>N z?>VbWR|xsnZfR=IF?kUU{<5si$`Mrh<0sbzgSJ;bhiQt>4qlm0I(1E)C{5J>ui~{(5O0RfwiO*c~ zJHPb-zrPn)kuGWNwuKH$Y4j}TKLtnxj(H3CfcRrHC{?B`ygb4$lhidG;OGADo(=9E9a>tvq^o85!=l#d^!zIf}ipAWbe z-)QLu>0gO!ud%1nQ*)qGy^;3SbpzA~_J7Aonw5NtUZPVJQ3(4?hE+dpq4{@SGWUoI?mC)sV_|8r!OHv zcV4bZ@?XmGcYNE2-~IFjg!l7jYIU+fpC4kK&ZbqhT<&G`HXJ-1ad1_c{*9vg4)DW# z)dc%@%D-}TGLk>*KyBZ2zDvko^%F*Vq8Qr0kiRik+Rc0DHS-+aG7 zwEwlk@*SlV_U~%zM!$vq3;QpB|0nu?#Ft5dPmthh2{e|1bQ%@c-s?K=l7> zkN21KkZG3YcAyPfA#mT{mWEDd(}0Pc%3BQu@bWf|3`Z`Ak0tEi9EZaG17YXx(iHY@PAe-POW40T4u$=@v(-Qx zFK7Q-wdcS54A@^VCwWIKA^$+g*cTuai_6a2l!(3Hg`IYxUFoA9wQf z_INq@pM=a-0V^wsFL3u|3JuJzix=Pa@XQ(^6#jvCF64lS!;6$w2*Z`U29Fq z-{10=DUUW{4WOyObUBxPT#cC^Q_*%#_YTVRVv3u3M7idxczb_)ygY!8Dg%f^)8P0@ ze~MLlQl!#~4k-;a`LhoZ@^>n~vtQ#U|IhRPUes*WhBf3yAE(Fg%$(OvDzk=81-})^ zVm~ewTXXNR7iU}T)@&-aXA_5}VMAOjLs}#sr4;+q(1GSc{ss=u|MD;U{?{Q2fvn+# zy^=pkHl-M_-e;F(tKEjH(KN{FKRf<3(A7FI|-n+kS``iYw zuH>lxzp0S_StftBecp2AUq$&XFVgD6##4&{ZYc}pF6!s52f%`H8vHp)c%MI)LxL$oT$?8GG;)N3{R$Jg-0OUwvYv6suy&leg%f|NH{k z4h9Bz3p~&6pG*(jEmLlaV{qA%d@DL9jsL^;i`54}M0s+V=YLknoxk1a z3$kOVb1vql@DnMhX+<*jbY95cll)~_mVkTgc6$LrJOlIscG+sP%GN@Jd|j^nS8Jrw zO!4)bhWhKGsXbY+GKrACk#75^{fEpYi^U=p7Z-=;=jY#(mzQ^+H^@i){M@|zGV}B9 zE6C5kH^cJFxMXEqR5CqC$ut_^&=)*FCulO}BBVn8{BX&L1GC43{kyY&mOo@KS*=#7 zu&_`nC@AnA`2|uouCrJQxX;!Y-z46uz;gWof(BGh_N*g9{_f;&v)NR+7Znvr+1c4r zR#ukR$j;1?PG@IA_Ubq3n@F=Ke|O9{jyw6OQS-k%m)N_rLjIol67sKVzpv#d{J&?-^v_vg|DJgi_OIDv zRfodQ@_Iy?g0Go+}>Yl2>JU*!k$xc z%D)N1cYrOx@0A4WQ2|g%T@v#5EII$2g`D{Le-MBVO` z2r?2GsRh5Uv8FQ)^-{^1);>V@e4`O7y~ zMLNR&o6&1w|HA$&zy4!Y*t55J2>TcI@6-X&|99KR5&mEJf8qbl>450}d)o$AXMEBA zi}qi%{}t(gX#dUme&+G4@c$2DofW@*udlk(%Ah59jzr@5kNN&Te`~EN(4sD24@u2) z=$jt+K1IBFJ9pwd zF~zTkANa&azLw#-5m-x3N~MX_Eq|UL!#cq2fX_&vTcu)(?GO9NVjC$0aw!fWpv2TV z6rtQ)4~B(~QV~TF>Ztm)Llm49Nlp7VfX~L7QoeG=^U|1mZ%o#&akc#Y6Y_T+sUK`2 zi`&Oyth2=6Wms+Mx7hf5Y_$X^KYwKqUlBu<7>*hx(|ua|mW4L1Ukr8PI;%3Q42OOd zYBeM#lTE^R85#ZTWXrOHJ^qKaJk{m4IH+x~LH-UM;C7yMp$O{+-djfg)zN3G2Ju#y zWg*t%b_oxH14ePbqRrG_*v)I-_8MtE` zvcYy7Hsr7)wxv>N$Fxw3s}J@26~)ui*2gn*%rHJz1a-i7C{f}9VgGKlfY_mBMJFe zuV{I+RV#ru@>;upYo+I~ldg7dewTTKj`;rg?I6;TKc`pIe@oIp6tT3t;&(-SNK$AS zz5@H363eDv8-7EZRQpr)ALUoKP7eFx@(1*{X2x26;?! zzjE3~lGL=xEkg&7njV)CfBReiN_!|GCynme|Lu~89*MeQThPKmBYJgS*Xq59Ve`h_ z_`irxT8wGcI5c6z9Z3;=>o>aK#s~Y({lB0!4O_Nb*x~#UuM9osjlP{n51D!2uv_kV zsbJF7AD@17e*gHpFMs{(rJqjB+LgZVwtElTmo0m2_-`|=mnS`c_^!!!&;PAn#~&Z~ zPMZDQ!rW)RTXg5&F*mdt@%om=kIf1l`o+N?zV7k;*h|_gpG?j>@?hw=yqoR)vai|k z(>m(9AUUo_m+{i)JDNr;TpaXntKQO`<3a}gdyh11UOTDV6~Ud&ob35ZtEa7<-hVgf zz^eyF?h4=5y`8jo_@@20|4f(saNZ2*(4lMAMO}2)-=AIf;Gob^zb5_m-;-18FG|`T z@%q|D%fDE0QRkT*m%b7d)8hI2{u&pP8U5r#H*Y!7=H$wQ2g7G)KR2T9<|hBUh?M0= zCgjY@YA;_qAUfr(Q`av$v$XkZZ*N&Kc=$Hysjll165dE!kowt6aZ-aPT0HdF$bKI^ z-e~2Pwf*S&$$c&z9QxCs|2BO2%ls8l3wlc(;tmean0wwmzYZCC^HtjyE)N;mWaPBh zhD6SeTmEdD^(`*zdo()q3j2>Qo>#b|>DJfU&yerEv}4Ct$2S~2Vr7dqEC1ZoZsO{t zbK+i}-EP{@{NLn)o3efw*fIF~3tnsUO~P+i4f^81$WyD<5AANfIc$Hk`um3Lzh7Fn z{IeCGg>Sy8U;p52si?5KykyJYPb_Zp?c?K~w!C>spMGDzE~QPH6~3X})yjbSUly%z zy0y`$#)DU+e0o9CHh+)X_|tRke{Jx5OpNWZSy?CPkaF^~c|PZOE>my;Hu3 zJ8X>)`t-LU-@kEU$|Lh1nSXfujNAT5Y1c>kX5OY*ZMR-?f9_Sc|2ELRa{trUZQt?Z z!0snMu};4IyZBiNXR=mZb>@Y^%_coFB`bbONCGT{mpkeHop9sC7T|eBxN^RGt<)h_q635Cx1Fn zQd&-*@p;;owIlDkYL7I#oh3P@$+%6uHvjehvZOwf9;tWY^rg+{&(IeJUiW(5#zyH6 zWl95^+1K`tNVzyW?&O85JIonAbegr((}iEQkh;AZa=KG)zsP$N2LFXQP zrO+-tH$quFdjFhvIt-dOW7@L|T22TJNj$zLc+AdeAAPxIz>VpdEtjqO{+hzE%f~#N zdGJ4S?@1KAF!TT7e~y^nY|PCMOLt`M-ul&_;je^erp2UuV~^?Gde=eV7>wN3QQ&Quv?Ird5?!$=r^}bmb`pk|liLvWKuLunp^i@Fh@i#uQlGiyyG61*>(@0$js zSrvJr$I8n_Egg4%<7OSlJUIK!32lEX7(eQs)^SItHkv)}t6y$ga$wb$e@T0G-@0|z zONXZ}?b&eoO>;huSS4=?`hC+AXR^9)&Ux=lWTXE+z9{RlKQ5d3?qwl~?={_e+f}y* zXXk9%wCTeO$ITdiv_WL^kLq1DYU=$r9vIbk)n%Jo-gRWhw$`KP|ABPBp4fgv`-sK| z2DcuxBI>n%d+L9XVePP?Z}_L(f_t_8DLmoOq(1eocy4)NhuKfh*wPD0FFZK>scUYS zq6}}kXj{;$4dlozry{cZAAfVJbaKTLTQ7Y%xc$G+`RLyRJ3fBt-nkPlSQff@Pv0~5 z&RV)Nb;3kt)wUToJ@C|z4@=|6ZAx4F)1>FEV}kp=+@Q(7-@fQ|TUh@*>XMjx#RrF; z?QRL1ZGR?oSs!Wsc*Ol?mwnfB$f(1`_PtXMu5Ej4O~zd1vrlIqh~6+DuES3UxAk1Q z`%ZWc?Njux6GjARkG$noA$$a~$#3hfP8u;#y zZ;l;#!LLcD&ky?K&?O;7A5Tk~@?%!t-{TKH{pQ?t=e~IdM7HA@OI*u^s}`;q^4)=< za}usif6I1R@Xd1yww$y!d3EZbQ`RS6xi)BVf6#npcw*M7QT@8Mc_RPZto}z^?SJvj zrpv>mkq?htJ@TbCw>KNGHGE3w!1gmYvt+lQ-}!Od?N?sfbVk|-4f?d*aqUxYY=0?a z=!%f_H$2*Q)P`A0hJ4CtJ{FVx@|Cw0*={XtYn}Mwv^%a#+BW5i5zBv=y)6%=`Cq~Yb^ERU?OR&+T-3hD;1>VA_QM;baT}XY?-6-&S<;>HNlz9; z?Emn{RrNC_F1z8gE2Q~Jug4}mIeI{LkFC-7|1jr3&aA7x@4kJ>^v;)#eBfl`Z$H|8 zrs&hvuN6EU^3UDJR;2bREM9Qp>h7Zl?C43qo;mW;vyVwvpY!rp6Z9F=?ejvr6%-xD@>Q_=H(ys)W}l%I9gb3f&dUw`zC;Q!3)vVYN^ zdDMG?^vZMKo*n$NZ)r{ygyOZK;h1_KVsWl@&B>!e7$ST`!#f?_2LWPEG&SigUsBJjF=l@ zdo4Qu)P+|_MeA-z4Ed_*^^LlsM{{ED#Q)u~^M;58GeRwyF?0Kln>H@xpQi017TB&! zJT$0x;h=Y>Kbao%__3y2|H=qnJHLO!Q{CG|>}nJJXZTCcjdvtbxgL|}%E$a`ZL_}j zEUMRaN6eg=pKd#6%%*PdO#E--puR_s1#i4zcUI1v-v|F(@KXKnhhKC3wD_4Dl{Q&v z*E})H-s#)#Ru2l^{qgz*FRsY^e&r)~-caw$mJuDcCIt6cer(08(`$?O-CQ(r+OGBo z!oJ`B`o?E0&)l83sr&4c*Sv7z+|gklU;JIr>{r*0DV}i5x^(;y+p%~4?s3l}VJQ!N z*&@0&O7+5P+EXPf7aYjXKJzpZ+%)rVbw-}nB+ z;hVpDI8y39E~UZOYu~x{{D)dhD!k;H!>7JoyW-08X7yjT?SideHs9D_eXo7@FL)@q zTjG*+=f1Kpa$3aCyTW(09K2=eWO}Nojv%~{>-V&Rrg1|u(8R^8R>&>+q=GA3kWOd^2mOf zpWK%AWW6&n3vVm<*YeMM^zx;D>seamyUpt;^ye;Lr$2k{?zy-}kZ!s0j-hLYjD7k4 E0ml}Q#sB~S literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y From 82c641035adaf7aa55e58178fbc770db3891ead0 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 14:36:44 +0200 Subject: [PATCH 047/144] Format controllers --- lib/nulla_web/controllers/follow_controller.ex | 4 ++-- lib/nulla_web/controllers/note_controller.ex | 2 +- lib/nulla_web/controllers/user_controller.ex | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index a53fed8..6f8a76a 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -1,9 +1,9 @@ defmodule NullaWeb.FollowController do use NullaWeb, :controller - alias Nulla.Models.User - alias Nulla.Models.InstanceSettings alias Nulla.ActivityPub alias Nulla.Utils + alias Nulla.Models.User + alias Nulla.Models.InstanceSettings def following(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index 4ad29c9..bff4723 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -1,9 +1,9 @@ defmodule NullaWeb.NoteController do use NullaWeb, :controller alias Nulla.Repo + alias Nulla.ActivityPub alias Nulla.Models.Note alias Nulla.Models.InstanceSettings - alias Nulla.ActivityPub def index(conn, _params) do notes = Notes.list_notes() diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 45ebd86..a608f48 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -1,10 +1,10 @@ defmodule NullaWeb.UserController do use NullaWeb, :controller + alias Nulla.ActivityPub + alias Nulla.Utils alias Nulla.Models.User alias Nulla.Models.Note alias Nulla.Models.InstanceSettings - alias Nulla.ActivityPub - alias Nulla.Utils def show(conn, %{"username" => username}) do accept = List.first(get_req_header(conn, "accept")) From b63eaa34be79ecc3b469c20cc82fc5a365d9e00c Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 16:05:18 +0200 Subject: [PATCH 048/144] Add Webfinger --- lib/nulla/activitypub.ex | 13 +++++++ lib/nulla/models/user.ex | 2 ++ .../controllers/webfinger_controller.ex | 34 +++++++++++++++++++ lib/nulla_web/router.ex | 2 ++ 4 files changed, 51 insertions(+) create mode 100644 lib/nulla_web/controllers/webfinger_controller.ex diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index e3c93f8..2e58798 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -243,4 +243,17 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end + + def webfinger(domain, username, resource) do + Jason.OrderedObject.new( + subject: resource, + links: [ + Jason.OrderedObject.new( + rel: "self", + type: "application/activity+json", + href: "https://#{domain}/@#{username}" + ) + ] + ) + end end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index cd20f48..caf90c5 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -77,5 +77,7 @@ defmodule Nulla.Models.User do ]) end + def get_user_by_username(username), do: Repo.get_by(User, username: username) + def get_user_by_username!(username), do: Repo.get_by!(User, username: username) end diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex new file mode 100644 index 0000000..ab6e2bf --- /dev/null +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -0,0 +1,34 @@ +defmodule NullaWeb.WebfingerController do + use NullaWeb, :controller + alias Nulla.Repo + alias Nulla.ActivityPub + alias Nulla.Models.User + alias Nulla.Models.InstanceSettings + + def show(conn, %{"resource" => resource}) do + case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do + [_, username, domain] -> + case User.get_user_by_username(username) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + user -> + instance_settings = InstanceSettings.get_instance_settings!() + + if domain == instance_settings.domain do + json(conn, ActivityPub.webfinger(domain, username, resource)) + else + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + end + end + _ -> + conn + |> put_status(:bad_request) + |> json(%{error: "Bad Request"}) + end + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 04268ed..0ad10df 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -21,6 +21,8 @@ defmodule NullaWeb.Router do scope "/", NullaWeb do pipe_through :browser + get "/.well-known/webfinger", WebfingerController, :show + get "/@:username", UserController, :show get "/@:username/following", FollowController, :following get "/@:username/followers", FollowController, :followers From eecdf86849f039b013f0901febf3fd718e892892 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 16:11:35 +0200 Subject: [PATCH 049/144] mix format --- lib/nulla_web/controllers/webfinger_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index ab6e2bf..fb22ada 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -25,6 +25,7 @@ defmodule NullaWeb.WebfingerController do |> json(%{error: "Not Found"}) end end + _ -> conn |> put_status(:bad_request) From 4a890a39f47966797e6cca3c8f4120483657a5bb Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 12 Jun 2025 23:22:42 +0200 Subject: [PATCH 050/144] Add nodeinfo --- lib/nulla/activitypub.ex | 52 ++++++++++++++++++- .../controllers/nodeinfo_controller.ex | 28 ++++++++++ .../controllers/webfinger_controller.ex | 2 +- lib/nulla_web/router.ex | 10 ++-- 4 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 lib/nulla_web/controllers/nodeinfo_controller.ex diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 2e58798..019aef8 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -137,8 +137,7 @@ defmodule Nulla.ActivityPub do id: "https://#{domain}/activities/#{activity.id}", type: activity.type, actor: activity.actor, - object: activity.object, - to: activity.to + object: activity.object ) end @@ -244,6 +243,7 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end + @spec webfinger(String.t(), String.t(), String.t()) :: Jason.OrderedObject.t() def webfinger(domain, username, resource) do Jason.OrderedObject.new( subject: resource, @@ -256,4 +256,52 @@ defmodule Nulla.ActivityPub do ] ) end + + @spec nodeinfo(String.t()) :: Jason.OrderedObject.t() + def nodeinfo(domain) do + Jason.OrderedObject.new( + links: [ + Jason.OrderedObject.new( + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + href: "https://#{domain}/nodeinfo/2.0" + ) + ] + ) + end + + @spec nodeinfo(String.t(), Map.t(), Nulla.Models.InstanceSettings.t()) :: + Jason.OrderedObject.t() + def nodeinfo(version, users, instance) do + Jason.OrderedObject.new( + version: "2.0", + software: + Jason.OrderedObject.new( + name: "nulla", + version: version + ), + protocols: [ + "activitypub" + ], + services: + Jason.OrderedObject.new( + outbound: [], + inbound: [] + ), + usage: + Jason.OrderedObject.new( + users: + Jason.OrderedObject.new( + total: users.total, + activeMonth: users.month, + activeHalfyear: users.halfyear + ) + ), + openRegistrations: instance.registration, + metadata: + Jason.OrderedObject.new( + nodeName: instance.name, + nodeDescription: instance.description + ) + ) + end end diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/nodeinfo_controller.ex new file mode 100644 index 0000000..92efc88 --- /dev/null +++ b/lib/nulla_web/controllers/nodeinfo_controller.ex @@ -0,0 +1,28 @@ +defmodule NullaWeb.NodeinfoController do + use NullaWeb, :controller + alias Nulla.Repo + alias Nulla.ActivityPub + alias Nulla.Models.User + alias Nulla.Models.InstanceSettings + + def index(conn, _params) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + + json(conn, ActivityPub.nodeinfo(domain)) + end + + def show(conn, _params) do + version = Application.spec(:nulla, :vsn) |> to_string() + + users = %{ + total: 0, + month: 0, + halfyear: 0 + } + + instance_settings = InstanceSettings.get_instance_settings!() + + json(conn, ActivityPub.nodeinfo(version, users, instance_settings)) + end +end diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index fb22ada..e11afeb 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -5,7 +5,7 @@ defmodule NullaWeb.WebfingerController do alias Nulla.Models.User alias Nulla.Models.InstanceSettings - def show(conn, %{"resource" => resource}) do + def index(conn, %{"resource" => resource}) do case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do [_, username, domain] -> case User.get_user_by_username(username) do diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 0ad10df..ac27911 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -11,17 +11,15 @@ defmodule NullaWeb.Router do end pipeline :api do - plug :accepts, ["activity+json", "ld+json"] - - post "/inbox", InboxController, :receive - post "/@:username/inbox", InboxController, :receive - get "/@:username/outbox", OutboxController, :index + plug :accepts, ["json"] end scope "/", NullaWeb do pipe_through :browser - get "/.well-known/webfinger", WebfingerController, :show + get "/.well-known/webfinger", WebfingerController, :index + get "/.well-known/nodeinfo", NodeinfoController, :index + get "/nodeinfo/2.0", NodeinfoController, :show get "/@:username", UserController, :show get "/@:username/following", FollowController, :following From 44b484de216343beb373bef68fa52102537ccf12 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 13 Jun 2025 15:10:17 +0200 Subject: [PATCH 051/144] Add snowflake IDs --- config/config.exs | 4 ++ lib/nulla/application.ex | 5 +- lib/nulla/models/activity.ex | 1 + lib/nulla/models/bookmarks.ex | 1 + lib/nulla/models/follow.ex | 1 + lib/nulla/models/hashtag.ex | 1 + lib/nulla/models/media_attachment.ex | 1 + lib/nulla/models/moderation_log.ex | 1 + lib/nulla/models/note.ex | 1 + lib/nulla/models/notification.ex | 1 + lib/nulla/models/session.ex | 1 + lib/nulla/models/user.ex | 11 ++++ lib/nulla/snowflake.ex | 62 +++++++++++++++++++ ...0250527054942_create_instance_settings.exs | 9 ++- .../20250530110822_create_users.exs | 3 +- .../20250604083506_create_notes.exs | 3 +- .../20250606100445_create_bookmarks.exs | 3 +- .../20250606103230_create_notifications.exs | 3 +- ...20250606103527_create_moderations_logs.exs | 3 +- .../20250606103649_create_hashtags.exs | 3 +- .../20250606103707_create_follows.exs | 3 +- .../20250606131715_create_sessions.exs | 3 +- ...0250606132108_create_media_attachments.exs | 3 +- .../20250607124601_create_activities.exs | 3 +- 24 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 lib/nulla/snowflake.ex diff --git a/config/config.exs b/config/config.exs index 2c52b8a..fc9357b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,6 +22,10 @@ config :nulla, NullaWeb.Endpoint, pubsub_server: Nulla.PubSub, live_view: [signing_salt: "jcAt5/U+"] +# Snowflake configuration +config :nulla, :snowflake, + worker_id: 1 + # Configures the mailer # # By default it uses the "Local" adapter which stores the emails diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex index 7e86858..6832652 100644 --- a/lib/nulla/application.ex +++ b/lib/nulla/application.ex @@ -7,6 +7,8 @@ defmodule Nulla.Application do @impl true def start(_type, _args) do + worker_id = Application.fetch_env!(:nulla, :snowflake)[:worker_id] + children = [ NullaWeb.Telemetry, Nulla.Repo, @@ -17,7 +19,8 @@ defmodule Nulla.Application do # Start a worker by calling: Nulla.Worker.start_link(arg) # {Nulla.Worker, arg}, # Start to serve requests, typically the last entry - NullaWeb.Endpoint + NullaWeb.Endpoint, + {Nulla.Snowflake, worker_id} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index bf0101e..e41b879 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.Activity do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "activities" do field :type, :string field :actor, :string diff --git a/lib/nulla/models/bookmarks.ex b/lib/nulla/models/bookmarks.ex index b4e7a5b..1688167 100644 --- a/lib/nulla/models/bookmarks.ex +++ b/lib/nulla/models/bookmarks.ex @@ -5,6 +5,7 @@ defmodule Nulla.Models.Bookmark do alias Nulla.Repo alias Nulla.Models.Bookmark + @primary_key {:id, :integer, autogenerate: false} schema "bookmarks" do field :url, :string field :user_id, :id diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex index 0469dbd..3646881 100644 --- a/lib/nulla/models/follow.ex +++ b/lib/nulla/models/follow.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.Follow do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "follows" do belongs_to :user, Nulla.Models.User belongs_to :target, Nulla.Models.User diff --git a/lib/nulla/models/hashtag.ex b/lib/nulla/models/hashtag.ex index 0be647d..5e6d71e 100644 --- a/lib/nulla/models/hashtag.ex +++ b/lib/nulla/models/hashtag.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.Hashtag do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "hashtags" do field :tag, :string field :usage_count, :integer, default: 0 diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/models/media_attachment.ex index 9eca245..bafed6f 100644 --- a/lib/nulla/models/media_attachment.ex +++ b/lib/nulla/models/media_attachment.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.MediaAttachment do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "media_attachments" do field :file, :string field :mime_type, :string diff --git a/lib/nulla/models/moderation_log.ex b/lib/nulla/models/moderation_log.ex index f6d3469..7e30af1 100644 --- a/lib/nulla/models/moderation_log.ex +++ b/lib/nulla/models/moderation_log.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.ModerationLog do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "moderation_logs" do field :target_type, :string field :target_id, :string diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 57a07e6..efaf328 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -5,6 +5,7 @@ defmodule Nulla.Models.Note do alias Nulla.Repo alias Nulla.Models.Note + @primary_key {:id, :integer, autogenerate: false} schema "notes" do field :content, :string diff --git a/lib/nulla/models/notification.ex b/lib/nulla/models/notification.ex index c0d595c..946f58b 100644 --- a/lib/nulla/models/notification.ex +++ b/lib/nulla/models/notification.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.Notification do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "notifications" do field :type, :string field :data, :map diff --git a/lib/nulla/models/session.ex b/lib/nulla/models/session.ex index c505ad6..0eb45b7 100644 --- a/lib/nulla/models/session.ex +++ b/lib/nulla/models/session.ex @@ -2,6 +2,7 @@ defmodule Nulla.Models.Session do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :integer, autogenerate: false} schema "sessions" do field :token, :string field :user_agent, :string diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index caf90c5..b3ef7ce 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -2,8 +2,10 @@ defmodule Nulla.Models.User do use Ecto.Schema import Ecto.Changeset alias Nulla.Repo + alias Nulla.Snowflake alias Nulla.Models.User + @primary_key {:id, :integer, autogenerate: false} schema "users" do field :username, :string field :email, :string @@ -77,6 +79,15 @@ defmodule Nulla.Models.User do ]) end + def create_user(attrs) do + id = Snowflake.next_id() + + %User{} + |> User.changeset(attrs) + |> Ecto.Changeset.put_change(:id, id) + |> Repo.insert() + end + def get_user_by_username(username), do: Repo.get_by(User, username: username) def get_user_by_username!(username), do: Repo.get_by!(User, username: username) diff --git a/lib/nulla/snowflake.ex b/lib/nulla/snowflake.ex new file mode 100644 index 0000000..5b7c88e --- /dev/null +++ b/lib/nulla/snowflake.ex @@ -0,0 +1,62 @@ +defmodule Nulla.Snowflake do + use GenServer + import Bitwise + + @epoch :calendar.datetime_to_gregorian_seconds({{2020, 1, 1}, {0, 0, 0}}) * 1000 + @max_sequence 4095 + @time_shift 22 + @worker_shift 12 + + def start_link(worker_id) when worker_id in 0..1023 do + GenServer.start_link(__MODULE__, worker_id, name: __MODULE__) + end + + def next_id do + GenServer.call(__MODULE__, :next_id) + end + + @impl true + def init(worker_id) do + {:ok, %{last_timestamp: -1, sequence: 0, worker_id: worker_id}} + end + + @impl true + def handle_call(:next_id, _from, %{worker_id: worker_id} = state) do + timestamp = current_time() + + {timestamp, sequence, state} = + cond do + timestamp < state.last_timestamp -> + raise "Clock moved backwards" + + timestamp == state.last_timestamp and state.sequence < @max_sequence -> + {timestamp, state.sequence + 1, %{state | sequence: state.sequence + 1}} + + timestamp == state.last_timestamp -> + wait_for_next_millisecond(timestamp) + new_timestamp = current_time() + {new_timestamp, 0, %{state | last_timestamp: new_timestamp, sequence: 0}} + + true -> + {timestamp, 0, %{state | last_timestamp: timestamp, sequence: 0}} + end + + raw_id = + ((timestamp - @epoch) <<< @time_shift) + |> bor(worker_id <<< @worker_shift) + |> bor(sequence) + + id = Bitwise.band(raw_id, 0x7FFFFFFFFFFFFFFF) + + {:reply, id, %{state | last_timestamp: timestamp, sequence: sequence}} + end + + defp current_time do + System.system_time(:millisecond) + end + + defp wait_for_next_millisecond(last_ts) do + :timer.sleep(1) + if current_time() <= last_ts, do: wait_for_next_millisecond(last_ts), else: :ok + end +end diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index 3f8be91..64c45f0 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do use Ecto.Migration def change do - create table(:instance_settings) do + create table(:instance_settings, primary_key: false) do + add :id, :integer, primary_key: true add :name, :string, default: "Nulla", null: false add :description, :text, default: "Freedom Social Network", null: false add :domain, :string, default: "localhost", null: false @@ -16,6 +17,8 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do timestamps() end + execute "ALTER TABLE instance_settings ADD CONSTRAINT single_row CHECK (id = 1);" + flush() execute(fn -> @@ -31,11 +34,11 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do sql = """ INSERT INTO instance_settings ( - name, description, domain, registration, + id, name, description, domain, registration, max_characters, max_upload_size, api_offset, public_key, private_key, inserted_at, updated_at ) VALUES ( - 'Nulla', 'Freedom Social Network', '#{domain}', false, + 1, 'Nulla', 'Freedom Social Network', '#{domain}', false, 5000, 50, 100, #{esc.(public_key)}, #{esc.(private_key)}, '#{now}', '#{now}' diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs index cc7ab33..b597c2f 100644 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateUsers do use Ecto.Migration def change do - create table(:users) do + create table(:users, primary_key: false) do + add :id, :bigint, primary_key: true add :username, :string, null: false, unique: true add :email, :string add :password, :string diff --git a/priv/repo/migrations/20250604083506_create_notes.exs b/priv/repo/migrations/20250604083506_create_notes.exs index e14f519..975fd6f 100644 --- a/priv/repo/migrations/20250604083506_create_notes.exs +++ b/priv/repo/migrations/20250604083506_create_notes.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateNotes do use Ecto.Migration def change do - create table(:notes) do + create table(:notes, primary_key: false) do + add :id, :bigint, primary_key: true add :content, :text add :visibility, :string, default: "public" add :sensitive, :boolean, default: false diff --git a/priv/repo/migrations/20250606100445_create_bookmarks.exs b/priv/repo/migrations/20250606100445_create_bookmarks.exs index 810721e..5ddb447 100644 --- a/priv/repo/migrations/20250606100445_create_bookmarks.exs +++ b/priv/repo/migrations/20250606100445_create_bookmarks.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateBookmarks do use Ecto.Migration def change do - create table(:bookmarks) do + create table(:bookmarks, primary_key: false) do + add :id, :bigint, primary_key: true add :url, :string add :user_id, references(:users, on_delete: :delete_all) diff --git a/priv/repo/migrations/20250606103230_create_notifications.exs b/priv/repo/migrations/20250606103230_create_notifications.exs index 85ec848..ec8cbc0 100644 --- a/priv/repo/migrations/20250606103230_create_notifications.exs +++ b/priv/repo/migrations/20250606103230_create_notifications.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateNotifications do use Ecto.Migration def change do - create table(:notifications) do + create table(:notifications, primary_key: false) do + add :id, :bigint, primary_key: true add :user_id, references(:users, on_delete: :delete_all), null: false add :actor_id, references(:users, on_delete: :nilify_all) add :type, :string, null: false diff --git a/priv/repo/migrations/20250606103527_create_moderations_logs.exs b/priv/repo/migrations/20250606103527_create_moderations_logs.exs index 252231f..546f88e 100644 --- a/priv/repo/migrations/20250606103527_create_moderations_logs.exs +++ b/priv/repo/migrations/20250606103527_create_moderations_logs.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateModerationLogs do use Ecto.Migration def change do - create table(:moderation_logs) do + create table(:moderation_logs, primary_key: false) do + add :id, :bigint, primary_key: true add :moderator_id, references(:users, on_delete: :nilify_all), null: false add :target_type, :string, null: false add :target_id, :string, null: false diff --git a/priv/repo/migrations/20250606103649_create_hashtags.exs b/priv/repo/migrations/20250606103649_create_hashtags.exs index 6a155cc..ac94cb2 100644 --- a/priv/repo/migrations/20250606103649_create_hashtags.exs +++ b/priv/repo/migrations/20250606103649_create_hashtags.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateHashtags do use Ecto.Migration def change do - create table(:hashtags) do + create table(:hashtags, primary_key: false) do + add :id, :bigint, primary_key: true add :tag, :string, null: false add :usage_count, :integer, default: 0, null: false diff --git a/priv/repo/migrations/20250606103707_create_follows.exs b/priv/repo/migrations/20250606103707_create_follows.exs index 27f161f..b39244e 100644 --- a/priv/repo/migrations/20250606103707_create_follows.exs +++ b/priv/repo/migrations/20250606103707_create_follows.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateFollows do use Ecto.Migration def change do - create table(:follows) do + create table(:follows, primary_key: false) do + add :id, :bigint, primary_key: true add :user_id, references(:users, on_delete: :delete_all), null: false add :target_id, references(:users, on_delete: :delete_all), null: false diff --git a/priv/repo/migrations/20250606131715_create_sessions.exs b/priv/repo/migrations/20250606131715_create_sessions.exs index b11c9fc..3e0e9e2 100644 --- a/priv/repo/migrations/20250606131715_create_sessions.exs +++ b/priv/repo/migrations/20250606131715_create_sessions.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateSessions do use Ecto.Migration def change do - create table(:sessions) do + create table(:sessions, primary_key: false) do + add :id, :bigint, primary_key: true add :user_id, references(:users, on_delete: :delete_all), null: false add :token, :string, null: false add :user_agent, :string diff --git a/priv/repo/migrations/20250606132108_create_media_attachments.exs b/priv/repo/migrations/20250606132108_create_media_attachments.exs index 0aef7bd..1d9fe64 100644 --- a/priv/repo/migrations/20250606132108_create_media_attachments.exs +++ b/priv/repo/migrations/20250606132108_create_media_attachments.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do use Ecto.Migration def change do - create table(:media_attachments) do + create table(:media_attachments, primary_key: false) do + add :id, :bigint, primary_key: true add :note_id, references(:notes, on_delete: :delete_all), null: false add :file, :string, null: false add :mime_type, :string diff --git a/priv/repo/migrations/20250607124601_create_activities.exs b/priv/repo/migrations/20250607124601_create_activities.exs index 0ed3cf2..25addfd 100644 --- a/priv/repo/migrations/20250607124601_create_activities.exs +++ b/priv/repo/migrations/20250607124601_create_activities.exs @@ -2,7 +2,8 @@ defmodule Nulla.Repo.Migrations.CreateActivities do use Ecto.Migration def change do - create table(:activities) do + create table(:activities, primary_key: false) do + add :id, :bigint, primary_key: true add :type, :string, null: false add :actor, :string, null: false add :object, :map, null: false From 892e016b02eca437bdd5b361ed21a8de65cd0110 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 13 Jun 2025 15:18:44 +0200 Subject: [PATCH 052/144] Update user_controller.ex --- lib/nulla_web/controllers/user_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index a608f48..a5467e7 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -16,7 +16,7 @@ defmodule NullaWeb.UserController do if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.user(domain, user)) + |> send_resp(200, ActivityPub.user(domain, user)) else following = Utils.count_following_by_username!(user.username) followers = Utils.count_followers_by_username!(user.username) From 2cbd29963efd300759c40fd7c454e2ec8f911653 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 13 Jun 2025 15:29:25 +0200 Subject: [PATCH 053/144] Update README.md --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8771475..604408f 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,22 @@ Stack: Elixir + Phoenix + PostgreSQL - [ ] API compatible with other ActivityPub instances - [ ] JWT - [ ] Groups -- [ ] Formatting: Text / Markdown / HTML +- [ ] Formatting: big/medium/small title, bold, italic, strikethrough, link, code, code block - [ ] Links preview - [ ] Timelines: Home / Local / Global / Custom - [ ] Global search - [ ] Bookmarks - [ ] Profile links verification -- [ ] Ordering profile links +- [ ] Links preview - [ ] Import/Export posts -- [ ] Sync user settings on the server +- [x] Sync user settings on the server - [ ] Restricted direct messages - [ ] Direct messages tab -- [ ] Multiple accounts ### Server configuration -* Post preview -* Character limit per post * Disk space limit per user -* Enable/Disable custom emoji for whole instance * Limit on posts (count/time) -* Limit on storage ### User settings From fd15cc089b9bb04aaa5d8197a040db9480556141 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 17:15:32 +0200 Subject: [PATCH 054/144] Rename key_gen.ex to keygen.ex --- lib/nulla/{key_gen.ex => keygen.ex} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/nulla/{key_gen.ex => keygen.ex} (100%) diff --git a/lib/nulla/key_gen.ex b/lib/nulla/keygen.ex similarity index 100% rename from lib/nulla/key_gen.ex rename to lib/nulla/keygen.ex From 49ac2bbe6f9527154868c2d5b64675947e50315d Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 18:50:54 +0200 Subject: [PATCH 055/144] Update nodeinfo --- lib/nulla/models/user.ex | 32 +++++++++++++++++-- .../controllers/nodeinfo_controller.ex | 11 +++++-- .../20250530110822_create_users.exs | 4 ++- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index b3ef7ce..ce1ce22 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -1,6 +1,7 @@ defmodule Nulla.Models.User do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Nulla.Repo alias Nulla.Snowflake alias Nulla.Models.User @@ -8,6 +9,7 @@ defmodule Nulla.Models.User do @primary_key {:id, :integer, autogenerate: false} schema "users" do field :username, :string + field :domain, :string field :email, :string field :password, :string field :is_moderator, :boolean, default: false @@ -15,7 +17,7 @@ defmodule Nulla.Models.User do field :bio, :string field :location, :string field :birthday, :date - field :fields, :map + field :fields, {:array, :map} field :tags, {:array, :string} field :follow_approval, :boolean, default: false field :is_bot, :boolean, default: false @@ -26,6 +28,7 @@ defmodule Nulla.Models.User do field :public_key, :string field :avatar, :string field :banner, :string + field :last_active_at, :utc_datetime has_many :user_sessions, Nulla.Models.Session has_many :notes, Nulla.Models.Note @@ -39,6 +42,7 @@ defmodule Nulla.Models.User do user |> cast(attrs, [ :username, + :domain, :email, :password, :is_moderator, @@ -55,10 +59,12 @@ defmodule Nulla.Models.User do :private_key, :public_key, :avatar, - :banner + :banner, + :last_active_at ]) |> validate_required([ :username, + :domain, :email, :password, :is_moderator, @@ -75,7 +81,8 @@ defmodule Nulla.Models.User do :private_key, :public_key, :avatar, - :banner + :banner, + :last_active_at ]) end @@ -91,4 +98,23 @@ defmodule Nulla.Models.User do def get_user_by_username(username), do: Repo.get_by(User, username: username) def get_user_by_username!(username), do: Repo.get_by!(User, username: username) + + def get_total_users_count(domain) do + Repo.aggregate(from(u in User, where: u.domain == ^domain), :count, :id) + end + + def get_active_users_count(domain, days) do + cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second) + + from(u in User, + where: u.domain == ^domain and u.last_active_at > ^cutoff + ) + |> Repo.aggregate(:count, :id) + end + + def update_last_active(user) do + user + |> Ecto.Changeset.change(last_active_at: DateTime.utc_now()) + |> Repo.update() + end end diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/nodeinfo_controller.ex index 92efc88..46a3bed 100644 --- a/lib/nulla_web/controllers/nodeinfo_controller.ex +++ b/lib/nulla_web/controllers/nodeinfo_controller.ex @@ -14,11 +14,16 @@ defmodule NullaWeb.NodeinfoController do def show(conn, _params) do version = Application.spec(:nulla, :vsn) |> to_string() + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + total = User.get_total_users_count(domain) + month = User.get_active_users_count(domain, 30) + halfyear = User.get_active_users_count(domain, 180) users = %{ - total: 0, - month: 0, - halfyear: 0 + total: total, + month: month, + halfyear: halfyear } instance_settings = InstanceSettings.get_instance_settings!() diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs index b597c2f..2b48cfa 100644 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ b/priv/repo/migrations/20250530110822_create_users.exs @@ -5,6 +5,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do create table(:users, primary_key: false) do add :id, :bigint, primary_key: true add :username, :string, null: false, unique: true + add :domain, :string, null: false add :email, :string add :password, :string add :is_moderator, :boolean, default: false, null: false @@ -12,7 +13,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :bio, :text add :location, :string add :birthday, :date - add :fields, :map + add :fields, :jsonb, default: "[]", null: false add :tags, {:array, :string} add :follow_approval, :boolean, default: false, null: false add :is_bot, :boolean, default: false, null: false @@ -23,6 +24,7 @@ defmodule Nulla.Repo.Migrations.CreateUsers do add :public_key, :string, null: false add :avatar, :string add :banner, :string + add :last_active_at, :utc_datetime timestamps(type: :utc_datetime) end From 83182b9e7bfb0d7e30ce8cb2fe309f2e839427c3 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 18:52:04 +0200 Subject: [PATCH 056/144] Remove unneeded templates --- lib/nulla_web/components/templates.ex | 22 ------------------ .../components/templates/note/edit.html.heex | 8 ------- .../components/templates/note/index.html.heex | 23 ------------------- .../components/templates/note/new.html.heex | 8 ------- .../templates/note/note_form.html.heex | 9 -------- .../components/templates/note/show.html.heex | 15 ------------ .../components/templates/user/edit.html.heex | 8 ------- .../components/templates/user/index.html.heex | 23 ------------------- .../components/templates/user/new.html.heex | 8 ------- .../templates/user/user_form.html.heex | 9 -------- 10 files changed, 133 deletions(-) delete mode 100644 lib/nulla_web/components/templates/note/edit.html.heex delete mode 100644 lib/nulla_web/components/templates/note/index.html.heex delete mode 100644 lib/nulla_web/components/templates/note/new.html.heex delete mode 100644 lib/nulla_web/components/templates/note/note_form.html.heex delete mode 100644 lib/nulla_web/components/templates/note/show.html.heex delete mode 100644 lib/nulla_web/components/templates/user/edit.html.heex delete mode 100644 lib/nulla_web/components/templates/user/index.html.heex delete mode 100644 lib/nulla_web/components/templates/user/new.html.heex delete mode 100644 lib/nulla_web/components/templates/user/user_form.html.heex diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index 87fc303..acec111 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -3,14 +3,6 @@ defmodule NullaWeb.UserHTML do embed_templates "templates/user/*" - @doc """ - Renders a user form. - """ - attr :changeset, Ecto.Changeset, required: true - attr :action, :string, required: true - - def user_form(assigns) - def format_birthdate(date) do formatted = Date.to_string(date) |> String.replace("-", "/") age = Timex.diff(Timex.today(), date, :years) @@ -84,17 +76,3 @@ defmodule NullaWeb.UserHTML do end end end - -defmodule NullaWeb.NoteHTML do - use NullaWeb, :html - - embed_templates "templates/note/*" - - @doc """ - Renders a note form. - """ - attr :changeset, Ecto.Changeset, required: true - attr :action, :string, required: true - - def note_form(assigns) -end diff --git a/lib/nulla_web/components/templates/note/edit.html.heex b/lib/nulla_web/components/templates/note/edit.html.heex deleted file mode 100644 index 3bef388..0000000 --- a/lib/nulla_web/components/templates/note/edit.html.heex +++ /dev/null @@ -1,8 +0,0 @@ -<.header> - Edit Note {@note.id} - <:subtitle>Use this form to manage note records in your database. - - -<.note_form changeset={@changeset} action={~p"/notes/#{@note}"} /> - -<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/components/templates/note/index.html.heex b/lib/nulla_web/components/templates/note/index.html.heex deleted file mode 100644 index ffeedbc..0000000 --- a/lib/nulla_web/components/templates/note/index.html.heex +++ /dev/null @@ -1,23 +0,0 @@ -<.header> - Listing Notes - <:actions> - <.link href={~p"/notes/new"}> - <.button>New Note - - - - -<.table id="notes" rows={@notes} row_click={&JS.navigate(~p"/notes/#{&1}")}> - <:col :let={note} label="Content">{note.content} - <:action :let={note}> -
- <.link navigate={~p"/notes/#{note}"}>Show -
- <.link navigate={~p"/notes/#{note}/edit"}>Edit - - <:action :let={note}> - <.link href={~p"/notes/#{note}"} method="delete" data-confirm="Are you sure?"> - Delete - - - diff --git a/lib/nulla_web/components/templates/note/new.html.heex b/lib/nulla_web/components/templates/note/new.html.heex deleted file mode 100644 index 4cf47a4..0000000 --- a/lib/nulla_web/components/templates/note/new.html.heex +++ /dev/null @@ -1,8 +0,0 @@ -<.header> - New Note - <:subtitle>Use this form to manage note records in your database. - - -<.note_form changeset={@changeset} action={~p"/notes"} /> - -<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/components/templates/note/note_form.html.heex b/lib/nulla_web/components/templates/note/note_form.html.heex deleted file mode 100644 index da6ac0f..0000000 --- a/lib/nulla_web/components/templates/note/note_form.html.heex +++ /dev/null @@ -1,9 +0,0 @@ -<.simple_form :let={f} for={@changeset} action={@action}> - <.error :if={@changeset.action}> - Oops, something went wrong! Please check the errors below. - - <.input field={f[:content]} type="text" label="Content" /> - <:actions> - <.button>Save Note - - diff --git a/lib/nulla_web/components/templates/note/show.html.heex b/lib/nulla_web/components/templates/note/show.html.heex deleted file mode 100644 index d7f2f70..0000000 --- a/lib/nulla_web/components/templates/note/show.html.heex +++ /dev/null @@ -1,15 +0,0 @@ -<.header> - Note {@note.id} - <:subtitle>This is a note record from your database. - <:actions> - <.link href={~p"/notes/#{@note}/edit"}> - <.button>Edit note - - - - -<.list> - <:item title="Content">{@note.content} - - -<.back navigate={~p"/notes"}>Back to notes diff --git a/lib/nulla_web/components/templates/user/edit.html.heex b/lib/nulla_web/components/templates/user/edit.html.heex deleted file mode 100644 index 2f8aa66..0000000 --- a/lib/nulla_web/components/templates/user/edit.html.heex +++ /dev/null @@ -1,8 +0,0 @@ -<.header> - Edit User {@user.id} - <:subtitle>Use this form to manage user records in your database. - - -<.user_form changeset={@changeset} action={~p"/users/#{@user}"} /> - -<.back navigate={~p"/users"}>Back to users diff --git a/lib/nulla_web/components/templates/user/index.html.heex b/lib/nulla_web/components/templates/user/index.html.heex deleted file mode 100644 index 9eca5b7..0000000 --- a/lib/nulla_web/components/templates/user/index.html.heex +++ /dev/null @@ -1,23 +0,0 @@ -<.header> - Listing Users - <:actions> - <.link href={~p"/users/new"}> - <.button>New User - - - - -<.table id="users" rows={@users} row_click={&JS.navigate(~p"/users/#{&1}")}> - <:col :let={user} label="Username">{user.username} - <:action :let={user}> -
- <.link navigate={~p"/users/#{user}"}>Show -
- <.link navigate={~p"/users/#{user}/edit"}>Edit - - <:action :let={user}> - <.link href={~p"/users/#{user}"} method="delete" data-confirm="Are you sure?"> - Delete - - - diff --git a/lib/nulla_web/components/templates/user/new.html.heex b/lib/nulla_web/components/templates/user/new.html.heex deleted file mode 100644 index 9248fb0..0000000 --- a/lib/nulla_web/components/templates/user/new.html.heex +++ /dev/null @@ -1,8 +0,0 @@ -<.header> - New User - <:subtitle>Use this form to manage user records in your database. - - -<.user_form changeset={@changeset} action={~p"/users"} /> - -<.back navigate={~p"/users"}>Back to users diff --git a/lib/nulla_web/components/templates/user/user_form.html.heex b/lib/nulla_web/components/templates/user/user_form.html.heex deleted file mode 100644 index 6871618..0000000 --- a/lib/nulla_web/components/templates/user/user_form.html.heex +++ /dev/null @@ -1,9 +0,0 @@ -<.simple_form :let={f} for={@changeset} action={@action}> - <.error :if={@changeset.action}> - Oops, something went wrong! Please check the errors below. - - <.input field={f[:username]} type="text" label="Username" /> - <:actions> - <.button>Save User - - From c0bf6f62b49db6c4835d20d9f655f5d2a073f67e Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 18:52:23 +0200 Subject: [PATCH 057/144] mix format --- config/config.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index fc9357b..c3983d9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -23,8 +23,7 @@ config :nulla, NullaWeb.Endpoint, live_view: [signing_salt: "jcAt5/U+"] # Snowflake configuration -config :nulla, :snowflake, - worker_id: 1 +config :nulla, :snowflake, worker_id: 1 # Configures the mailer # From b582c93bb10851da988ea2a4ddcdb69fa41c361d Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 19:25:24 +0200 Subject: [PATCH 058/144] Fix user_controller.ex --- lib/nulla_web/controllers/user_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index a5467e7..d85d580 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -16,7 +16,7 @@ defmodule NullaWeb.UserController do if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") - |> send_resp(200, ActivityPub.user(domain, user)) + |> send_resp(200, Jason.encode!(ActivityPub.user(domain, user))) else following = Utils.count_following_by_username!(user.username) followers = Utils.count_followers_by_username!(user.username) From f05741edb5c66edc47d6c57b130748735e9d2099 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 14 Jun 2025 19:27:09 +0200 Subject: [PATCH 059/144] Add outbox --- lib/nulla/activitypub.ex | 12 ++++++++++++ lib/nulla/models/note.ex | 5 +++++ lib/nulla_web/controllers/outbox_controller.ex | 18 ++++++++++++++++++ lib/nulla_web/router.ex | 1 + 4 files changed, 36 insertions(+) create mode 100644 lib/nulla_web/controllers/outbox_controller.ex diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 019aef8..ec6332e 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -304,4 +304,16 @@ defmodule Nulla.ActivityPub do ) ) end + + @spec outbox(String.t(), String.t(), Integer.t()) :: Jason.OrderedObject.t() + def outbox(domain, username, total) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://#{domain}/@#{username}/outbox", + type: "OrderedCollection", + totalItems: total, + first: "https://#{domain}/@#{username}/outbox?page=true", + last: "https://#{domain}/@#{username}/outbox?min_id=0&page=true" + ) + end end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index efaf328..83e1eb5 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -33,4 +33,9 @@ defmodule Nulla.Models.Note do def get_note!(id), do: Repo.get!(Note, id) def get_all_notes!(user_id), do: Repo.all(from n in Note, where: n.user_id == ^user_id) + + def get_total_notes_count(user_id) do + from(n in Note, where: n.user_id == ^user_id) + |> Repo.aggregate(:count, :id) + end end diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex new file mode 100644 index 0000000..55d13da --- /dev/null +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -0,0 +1,18 @@ +defmodule NullaWeb.OutboxController do + use NullaWeb, :controller + alias Nulla.ActivityPub + alias Nulla.Models.User + alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings + + def show(conn, %{"username" => username}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Note.get_total_notes_count(user.id) + + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(ActivityPub.outbox(domain, username, total))) + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index ac27911..6e50c78 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -22,6 +22,7 @@ defmodule NullaWeb.Router do get "/nodeinfo/2.0", NodeinfoController, :show get "/@:username", UserController, :show + get "/@:username/outbox", OutboxController, :show get "/@:username/following", FollowController, :following get "/@:username/followers", FollowController, :followers get "/@:username/:note_id", NoteController, :show From 50abfe47482355233c340bfbfbf85ce3266120c4 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 15 Jun 2025 08:59:03 +0200 Subject: [PATCH 060/144] Update outbox --- lib/nulla/activitypub.ex | 40 ++++++++++++++ lib/nulla/models/note.ex | 18 ++++++- .../controllers/outbox_controller.ex | 53 ++++++++++++++++--- lib/nulla_web/controllers/user_controller.ex | 2 +- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index ec6332e..cbf5cfe 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -316,4 +316,44 @@ defmodule Nulla.ActivityPub do last: "https://#{domain}/@#{username}/outbox?min_id=0&page=true" ) end + + @spec outbox(String.t(), Integer.t(), Integer.t(), String.t(), List.t()) :: + Jason.OrderedObject.t() + def outbox(domain, username, max_id, min_id, items) do + Jason.OrderedObject.new( + "@context": [ + "https://www.w3.org/ns/activitystreams", + Jason.OrderedObject.new( + sensitive: "as:sensitive", + Hashtag: "as:Hashtag" + ) + ], + id: "https://#{domain}/@#{username}/outbox?page=true", + type: "OrderedCollectionPage", + next: "https://#{domain}/@#{username}/outbox?max_id=#{max_id}&page=true", + prev: "https://#{domain}/@#{username}/outbox?min_id=#{min_id}&page=true", + partOf: "https://#{domain}/@#{username}/outbox", + orderedItems: items + ) + end + + @spec render_activity(String.t(), Note.t()) :: Jason.OrderedObject.t() + def render_activity(domain, note) do + Jason.OrderedObject.new( + id: "https://#{domain}/@#{note.user.username}/#{note.id}/activity", + type: "Create", + actor: "https://#{domain}/@#{note.user.username}", + published: note.inserted_at |> DateTime.to_iso8601(), + to: ["https://www.w3.org/ns/activitystreams#Public"], + object: + Jason.OrderedObject.new( + id: "https://#{domain}/@#{note.user.username}/#{note.id}", + type: "Note", + content: note.content, + published: note.inserted_at |> DateTime.to_iso8601(), + attributedTo: "https://#{domain}/@#{note.user.username}", + to: ["https://www.w3.org/ns/activitystreams#Public"] + ) + ) + end end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 83e1eb5..7a7a20f 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -32,7 +32,23 @@ defmodule Nulla.Models.Note do def get_note!(id), do: Repo.get!(Note, id) - def get_all_notes!(user_id), do: Repo.all(from n in Note, where: n.user_id == ^user_id) + def get_latest_notes(user_id, limit \\ 20) do + from(n in Note, + where: n.user_id == ^user_id, + order_by: [desc: n.inserted_at], + limit: ^limit + ) + |> Repo.all() + end + + def get_before_notes(user_id, max_id, limit \\ 20) do + from(n in Note, + where: n.user_id == ^user_id and n.id < ^max_id, + order_by: [desc: n.inserted_at], + limit: ^limit + ) + |> Nulla.Repo.all() + end def get_total_notes_count(user_id) do from(n in Note, where: n.user_id == ^user_id) diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index 55d13da..fd9c8c4 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -5,14 +5,51 @@ defmodule NullaWeb.OutboxController do alias Nulla.Models.Note alias Nulla.Models.InstanceSettings - def show(conn, %{"username" => username}) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Note.get_total_notes_count(user.id) + def show(conn, %{"username" => username} = params) do + case Map.get(params, "page") do + "true" -> + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + max_id = params["max_id"] && String.to_integer(params["max_id"]) - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.outbox(domain, username, total))) + notes = + if max_id do + Note.get_before_notes(user.id, max_id) + else + Note.get_latest_notes(user.id) + end + + items = Enum.map(notes, &ActivityPub.render_activity(&1, domain)) + + next_max_id = + case List.last(notes) do + nil -> 0 + last -> last.id + end + + min_id = + case List.first(notes) do + nil -> 0 + first -> first.id + end + + conn + |> put_resp_content_type("application/activity+json") + |> send_resp( + 200, + Jason.encode!(ActivityPub.outbox(domain, username, next_max_id, min_id || 0, items)) + ) + + _ -> + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + user = User.get_user_by_username!(username) + total = Note.get_total_notes_count(user.id) + + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(ActivityPub.outbox(domain, username, total))) + end end end diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index d85d580..9263318 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -11,7 +11,7 @@ defmodule NullaWeb.UserController do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain user = User.get_user_by_username!(username) - notes = Note.get_all_notes!(user.id) + notes = Note.get_notes(user.id) if accept in ["application/activity+json", "application/ld+json"] do conn From 57efda763812bfb23f78ac312c57436fbf56ee07 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 15 Jun 2025 19:33:40 +0200 Subject: [PATCH 061/144] Update migrations --- ...0250527054942_create_instance_settings.exs | 4 +-- .../20250530110822_create_users.exs | 32 ------------------ .../20250615130714_create_actors.exs | 33 +++++++++++++++++++ .../20250615131158_create_users.exs | 20 +++++++++++ ...es.exs => 20250615131431_create_notes.exs} | 0 ...0250615131534_create_moderations_logs.exs} | 0 ...exs => 20250615131610_create_sessions.exs} | 0 ...250615131644_create_media_attachments.exs} | 0 ...xs => 20250615131800_create_bookmarks.exs} | 0 ....exs => 20250615131836_create_follows.exs} | 0 ...s => 20250615131856_create_activities.exs} | 0 ...> 20250615132202_create_notifications.exs} | 2 +- 12 files changed, 56 insertions(+), 35 deletions(-) delete mode 100644 priv/repo/migrations/20250530110822_create_users.exs create mode 100644 priv/repo/migrations/20250615130714_create_actors.exs create mode 100644 priv/repo/migrations/20250615131158_create_users.exs rename priv/repo/migrations/{20250604083506_create_notes.exs => 20250615131431_create_notes.exs} (100%) rename priv/repo/migrations/{20250606103527_create_moderations_logs.exs => 20250615131534_create_moderations_logs.exs} (100%) rename priv/repo/migrations/{20250606131715_create_sessions.exs => 20250615131610_create_sessions.exs} (100%) rename priv/repo/migrations/{20250606132108_create_media_attachments.exs => 20250615131644_create_media_attachments.exs} (100%) rename priv/repo/migrations/{20250606100445_create_bookmarks.exs => 20250615131800_create_bookmarks.exs} (100%) rename priv/repo/migrations/{20250606103707_create_follows.exs => 20250615131836_create_follows.exs} (100%) rename priv/repo/migrations/{20250607124601_create_activities.exs => 20250615131856_create_activities.exs} (100%) rename priv/repo/migrations/{20250606103230_create_notifications.exs => 20250615132202_create_notifications.exs} (88%) diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index 64c45f0..0f63ca5 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -22,8 +22,8 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do flush() execute(fn -> - {public_key, private_key} = Nulla.KeyGen.generate_keys() - now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + {public_key, private_key} = Nulla.KeyGen.gen() + now = DateTime.utc_now() domain = Application.get_env(:nulla, NullaWeb.Endpoint, []) diff --git a/priv/repo/migrations/20250530110822_create_users.exs b/priv/repo/migrations/20250530110822_create_users.exs deleted file mode 100644 index 2b48cfa..0000000 --- a/priv/repo/migrations/20250530110822_create_users.exs +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateUsers do - use Ecto.Migration - - def change do - create table(:users, primary_key: false) do - add :id, :bigint, primary_key: true - add :username, :string, null: false, unique: true - add :domain, :string, null: false - add :email, :string - add :password, :string - add :is_moderator, :boolean, default: false, null: false - add :realname, :string - add :bio, :text - add :location, :string - add :birthday, :date - add :fields, :jsonb, default: "[]", null: false - add :tags, {:array, :string} - add :follow_approval, :boolean, default: false, null: false - add :is_bot, :boolean, default: false, null: false - add :is_discoverable, :boolean, default: true, null: false - add :is_indexable, :boolean, default: true, null: false - add :is_memorial, :boolean, default: false, null: false - add :private_key, :string, null: false - add :public_key, :string, null: false - add :avatar, :string - add :banner, :string - add :last_active_at, :utc_datetime - - timestamps(type: :utc_datetime) - end - end -end diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs new file mode 100644 index 0000000..355f3e0 --- /dev/null +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -0,0 +1,33 @@ +defmodule Nulla.Repo.Migrations.CreateActors do + use Ecto.Migration + + def change do + create table(:actors, primary_key: false) do + add :id, :bigint, primary_key: true + add :type, :string + add :following, :string + add :followers, :string + add :inbox, :string + add :outbox, :string + add :featured, :string + add :featuredTags, :string + add :preferredUsername, :string + add :name, :string + add :summary, :string + add :url, :string + add :manuallyApprovesFollowers, :boolean + add :discoverable, :boolean, default: true + add :indexable, :boolean, default: true + add :published, :utc_datetime + add :memorial, :boolean, default: false + add :publicKey, :map + add :tag, {:array, :map} + add :attachment, {:array, :map} + add :endpoints, :map + add :icon, :map + add :image, :map + add :vcard_bday, :date + add :vcard_Address, :string + end + end +end diff --git a/priv/repo/migrations/20250615131158_create_users.exs b/priv/repo/migrations/20250615131158_create_users.exs new file mode 100644 index 0000000..e89889b --- /dev/null +++ b/priv/repo/migrations/20250615131158_create_users.exs @@ -0,0 +1,20 @@ +defmodule Nulla.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users, primary_key: false) do + add :id, :bigint, primary_key: true + add :email, :string + add :password, :string + add :privateKeyPem, :string + add :last_active_at, :utc_datetime + + add :actor_id, references(:actors, column: :id, type: :bigint, on_delete: :delete_all), + null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:actor_id]) + end +end diff --git a/priv/repo/migrations/20250604083506_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs similarity index 100% rename from priv/repo/migrations/20250604083506_create_notes.exs rename to priv/repo/migrations/20250615131431_create_notes.exs diff --git a/priv/repo/migrations/20250606103527_create_moderations_logs.exs b/priv/repo/migrations/20250615131534_create_moderations_logs.exs similarity index 100% rename from priv/repo/migrations/20250606103527_create_moderations_logs.exs rename to priv/repo/migrations/20250615131534_create_moderations_logs.exs diff --git a/priv/repo/migrations/20250606131715_create_sessions.exs b/priv/repo/migrations/20250615131610_create_sessions.exs similarity index 100% rename from priv/repo/migrations/20250606131715_create_sessions.exs rename to priv/repo/migrations/20250615131610_create_sessions.exs diff --git a/priv/repo/migrations/20250606132108_create_media_attachments.exs b/priv/repo/migrations/20250615131644_create_media_attachments.exs similarity index 100% rename from priv/repo/migrations/20250606132108_create_media_attachments.exs rename to priv/repo/migrations/20250615131644_create_media_attachments.exs diff --git a/priv/repo/migrations/20250606100445_create_bookmarks.exs b/priv/repo/migrations/20250615131800_create_bookmarks.exs similarity index 100% rename from priv/repo/migrations/20250606100445_create_bookmarks.exs rename to priv/repo/migrations/20250615131800_create_bookmarks.exs diff --git a/priv/repo/migrations/20250606103707_create_follows.exs b/priv/repo/migrations/20250615131836_create_follows.exs similarity index 100% rename from priv/repo/migrations/20250606103707_create_follows.exs rename to priv/repo/migrations/20250615131836_create_follows.exs diff --git a/priv/repo/migrations/20250607124601_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs similarity index 100% rename from priv/repo/migrations/20250607124601_create_activities.exs rename to priv/repo/migrations/20250615131856_create_activities.exs diff --git a/priv/repo/migrations/20250606103230_create_notifications.exs b/priv/repo/migrations/20250615132202_create_notifications.exs similarity index 88% rename from priv/repo/migrations/20250606103230_create_notifications.exs rename to priv/repo/migrations/20250615132202_create_notifications.exs index ec8cbc0..34e3204 100644 --- a/priv/repo/migrations/20250606103230_create_notifications.exs +++ b/priv/repo/migrations/20250615132202_create_notifications.exs @@ -5,7 +5,7 @@ defmodule Nulla.Repo.Migrations.CreateNotifications do create table(:notifications, primary_key: false) do add :id, :bigint, primary_key: true add :user_id, references(:users, on_delete: :delete_all), null: false - add :actor_id, references(:users, on_delete: :nilify_all) + add :actor_id, references(:actors, on_delete: :nilify_all) add :type, :string, null: false add :data, :map add :read, :boolean, default: false, null: false From 62dbe3ef2441b3bfcd5c0ba38a031aa5a6737a1a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 15 Jun 2025 19:34:18 +0200 Subject: [PATCH 062/144] Update keygen.ex --- lib/nulla/keygen.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nulla/keygen.ex b/lib/nulla/keygen.ex index c803920..5cbaf06 100644 --- a/lib/nulla/keygen.ex +++ b/lib/nulla/keygen.ex @@ -1,5 +1,5 @@ defmodule Nulla.KeyGen do - def generate_keys do + def gen do rsa_key = :public_key.generate_key({:rsa, 2048, 65537}) {:RSAPrivateKey, :"two-prime", n, e, _d, _p, _q, _dp, _dq, _qi, _other} = rsa_key From b596606c14f3d8e8bfef01dfd6321ad4f64bff5f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 15 Jun 2025 19:35:52 +0200 Subject: [PATCH 063/144] Add actor.ex --- lib/nulla/models/actor.ex | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 lib/nulla/models/actor.ex diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex new file mode 100644 index 0000000..653c37f --- /dev/null +++ b/lib/nulla/models/actor.ex @@ -0,0 +1,109 @@ +defmodule Nulla.Models.Actor do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Nulla.Repo + alias Nulla.Snowflake + alias Nulla.Models.User + alias Nulla.Models.Note + + @primary_key {:id, :integer, autogenerate: false} + schema "actors" do + field :type, :string + field :following, :string + field :followers, :string + field :inbox, :string + field :outbox, :string + field :featured, :string + field :featuredTags, :string + field :preferredUsername, :string + field :name, :string + field :summary, :string + field :url, :string + field :manuallyApprovesFollowers, :boolean + field :discoverable, :boolean, default: true + field :indexable, :boolean, default: true + field :published, :utc_datetime + field :memorial, :boolean, default: false + field :publicKey, {:array, :map} + field :tag, {:array, :map} + field :attachment, {:array, :map} + field :endpoints, :map + field :icon, :map + field :image, :map + field :vcard_bday, :date + field :vcard_Address, :string + + has_one :user, User + has_many :notes, Note + has_many :media_attachments, through: [:notes, :media_attachments] + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + |> validate_required([ + :id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + end + + def create_user(attrs) when is_map(attrs) do + id = Snowflake.next_id() + + %__MODULE__{} + |> changeset(attrs) + |> Ecto.Changeset.put_change(:id, id) + |> Repo.insert() + end +end From 58049c93d4d6e4a4c316e6c246e7cefd08513a11 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 15 Jun 2025 19:36:03 +0200 Subject: [PATCH 064/144] Update --- lib/nulla/models/follow.ex | 11 +++ lib/nulla/models/notification.ex | 6 +- lib/nulla/models/user.ex | 84 +++++-------------- lib/nulla/utils.ex | 34 ++++++++ lib/nulla_web/controllers/inbox_controller.ex | 38 ++++----- lib/nulla_web/controllers/note_controller.ex | 60 +------------ .../controllers/outbox_controller.ex | 2 +- lib/nulla_web/router.ex | 29 +++++-- 8 files changed, 115 insertions(+), 149 deletions(-) diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex index 3646881..bcd92f7 100644 --- a/lib/nulla/models/follow.ex +++ b/lib/nulla/models/follow.ex @@ -1,6 +1,8 @@ defmodule Nulla.Models.Follow do use Ecto.Schema import Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Models.Follow @primary_key {:id, :integer, autogenerate: false} schema "follows" do @@ -17,4 +19,13 @@ defmodule Nulla.Models.Follow do |> validate_required([:user_id, :target_id]) |> unique_constraint([:user_id, :target_id]) end + + def create_follow(attrs) do + id = Snowflake.next_id() + + %Follow{} + |> Follow.changeset(attrs) + |> Ecto.Changeset.put_change(:id, id) + |> Repo.insert() + end end diff --git a/lib/nulla/models/notification.ex b/lib/nulla/models/notification.ex index 946f58b..70e1dcc 100644 --- a/lib/nulla/models/notification.ex +++ b/lib/nulla/models/notification.ex @@ -1,6 +1,8 @@ defmodule Nulla.Models.Notification do use Ecto.Schema import Ecto.Changeset + alias Nulla.Models.User + alias Nulla.Models.Actor @primary_key {:id, :integer, autogenerate: false} schema "notifications" do @@ -8,8 +10,8 @@ defmodule Nulla.Models.Notification do field :data, :map field :read, :boolean, default: false - belongs_to :user, Nulla.Models.User - belongs_to :actor, Nulla.Models.User + belongs_to :user, User + belongs_to :actor, Actor timestamps() end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index ce1ce22..4328761 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -5,34 +5,18 @@ defmodule Nulla.Models.User do alias Nulla.Repo alias Nulla.Snowflake alias Nulla.Models.User + alias Nulla.Models.Actor + alias Nulla.Models.Session @primary_key {:id, :integer, autogenerate: false} schema "users" do - field :username, :string - field :domain, :string field :email, :string field :password, :string - field :is_moderator, :boolean, default: false - field :realname, :string - field :bio, :string - field :location, :string - field :birthday, :date - field :fields, {:array, :map} - field :tags, {:array, :string} - field :follow_approval, :boolean, default: false - field :is_bot, :boolean, default: false - field :is_discoverable, :boolean, default: true - field :is_indexable, :boolean, default: true - field :is_memorial, :boolean, default: false - field :private_key, :string - field :public_key, :string - field :avatar, :string - field :banner, :string + field :privateKeyPem, :string field :last_active_at, :utc_datetime - has_many :user_sessions, Nulla.Models.Session - has_many :notes, Nulla.Models.Note - has_many :media_attachments, through: [:notes, :media_attachments] + belongs_to :actor, Actor, define_field: false, foreign_key: :id, type: :integer + has_many :user_sessions, Session timestamps(type: :utc_datetime) end @@ -41,57 +25,24 @@ defmodule Nulla.Models.User do def changeset(user, attrs) do user |> cast(attrs, [ - :username, - :domain, :email, :password, - :is_moderator, - :realname, - :bio, - :location, - :birthday, - :fields, - :follow_approval, - :is_bot, - :is_discoverable, - :is_indexable, - :is_memorial, - :private_key, - :public_key, - :avatar, - :banner, - :last_active_at + :privateKeyPem, + :last_active_at, + :actor_id ]) |> validate_required([ - :username, - :domain, :email, :password, - :is_moderator, - :realname, - :bio, - :location, - :birthday, - :fields, - :follow_approval, - :is_bot, - :is_discoverable, - :is_indexable, - :is_memorial, - :private_key, - :public_key, - :avatar, - :banner, - :last_active_at + :privateKeyPem, + :last_active_at, + :actor_id ]) end - def create_user(attrs) do - id = Snowflake.next_id() - - %User{} - |> User.changeset(attrs) - |> Ecto.Changeset.put_change(:id, id) + def create_user(attrs) when is_map(attrs) do + %__MODULE__{} + |> changeset(attrs) |> Repo.insert() end @@ -99,6 +50,13 @@ defmodule Nulla.Models.User do def get_user_by_username!(username), do: Repo.get_by!(User, username: username) + def get_user_by_username_and_domain(username, domain) do + from(u in User, + where: u.username == ^username and u.domain == ^domain + ) + |> Repo.one() + end + def get_total_users_count(domain) do Repo.aggregate(from(u in User, where: u.domain == ^domain), :count, :id) end diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index 80a66c3..fc788c0 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -87,4 +87,38 @@ defmodule Nulla.Utils do users end end + + def resolve_local_actor("https://" <> _ = uri) do + case URI.parse(uri).path do + "/@" <> username -> + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + + case User.get_user_by_username_and_domain(username, domain) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + + _ -> + {:error, :invalid_actor} + end + end + + def fetch_remote_actor(uri) do + request = + Finch.build(:get, uri, [ + {"Accept", "application/activity+json"} + ]) + + case Finch.request(request, MyApp.Finch) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, data} -> {:ok, data} + _ -> {:error, :invalid_json} + end + + _ -> + {:error, :actor_fetch_failed} + end + end end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index e4ed4c4..d0ba0e3 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,25 +1,23 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller + alias Nulla.Models.Follow + alias Nulla.Utils - def receive(conn, %{"type" => "Follow"} = activity) do - # Check signature - # Verify actor and object - # Save follow to db - # Send Accept or Reject - json(conn, %{"status" => "Follow received"}) - end - - def receive(conn, %{"type" => "Like"} = activity) do - # Process Like - json(conn, %{"status" => "Like received"}) - end - - def receive(conn, %{"type" => "Create"} = activity) do - # Create object and save - json(conn, %{"status" => "Object created"}) - end - - def receive(conn, _params) do - json(conn, %{"status" => "Unhandled type"}) + def inbox( + conn, + %{"type" => "Follow", "actor" => actor_uri, "object" => target_uri} = activity + ) do + with {:ok, target_user} <- Utils.resolve_local_actor(target_uri), + {:ok, remote_actor} <- Utils.fetch_remote_actor(actor_uri), + :ok <- HTTPSignature.verify(conn, remote_actor), + remote_user <- Follow.create_remote_user(remote_actor), + follow <- Follow.create_follow(%{user: remote_user, target: target_user}), + :ok <- Utils.send_accept_activity(remote_actor, target_user, follow, activity) do + json(conn, %{"status" => "Follow accepted"}) + else + error -> + IO.inspect(error, label: "Follow error") + json(conn, %{"error" => "Failed to process Follow"}) + end end end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index bff4723..2b271be 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -5,33 +5,11 @@ defmodule NullaWeb.NoteController do alias Nulla.Models.Note alias Nulla.Models.InstanceSettings - def index(conn, _params) do - notes = Notes.list_notes() - render(conn, :index, notes: notes) - end - - def new(conn, _params) do - changeset = Notes.change_note(%Note{}) - render(conn, :new, changeset: changeset) - end - - def create(conn, %{"note" => note_params}) do - case Notes.create_note(note_params) do - {:ok, note} -> - conn - |> put_flash(:info, "Note created successfully.") - |> redirect(to: ~p"/notes/#{note}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) - end - end - - def show(conn, %{"username" => username, "note_id" => note_id}) do + def show(conn, %{"username" => username, "id" => id}) do accept = List.first(get_req_header(conn, "accept")) instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - note = Note.get_note!(note_id) |> Repo.preload([:user, :media_attachments]) + note = Note.get_note!(id) |> Repo.preload([:user, :media_attachments]) if username != note.user.username do conn @@ -48,38 +26,4 @@ defmodule NullaWeb.NoteController do render(conn, :show, domain: domain, note: note, layout: false) end end - - # def show(conn, %{"id" => id}) do - # note = Notes.get_note!(id) - # render(conn, :show, note: note) - # end - - def edit(conn, %{"id" => id}) do - note = Notes.get_note!(id) - changeset = Notes.change_note(note) - render(conn, :edit, note: note, changeset: changeset) - end - - def update(conn, %{"id" => id, "note" => note_params}) do - note = Notes.get_note!(id) - - case Notes.update_note(note, note_params) do - {:ok, note} -> - conn - |> put_flash(:info, "Note updated successfully.") - |> redirect(to: ~p"/notes/#{note}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :edit, note: note, changeset: changeset) - end - end - - def delete(conn, %{"id" => id}) do - note = Notes.get_note!(id) - {:ok, _note} = Notes.delete_note(note) - - conn - |> put_flash(:info, "Note deleted successfully.") - |> redirect(to: ~p"/notes") - end end diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index fd9c8c4..b62214b 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -5,7 +5,7 @@ defmodule NullaWeb.OutboxController do alias Nulla.Models.Note alias Nulla.Models.InstanceSettings - def show(conn, %{"username" => username} = params) do + def outbox(conn, %{"username" => username} = params) do case Map.get(params, "page") do "true" -> instance_settings = InstanceSettings.get_instance_settings!() diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 6e50c78..edaaf09 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -20,12 +20,31 @@ defmodule NullaWeb.Router do get "/.well-known/webfinger", WebfingerController, :index get "/.well-known/nodeinfo", NodeinfoController, :index get "/nodeinfo/2.0", NodeinfoController, :show + post "/inbox", InboxController, :inbox - get "/@:username", UserController, :show - get "/@:username/outbox", OutboxController, :show - get "/@:username/following", FollowController, :following - get "/@:username/followers", FollowController, :followers - get "/@:username/:note_id", NoteController, :show + scope "/auth" do + get "/sign_in", AuthController, :sign_in + post "/sign_out", AuthController, :sign_out + get "/sign_up", AuthController, :sign_up + end + + scope "/users/:username" do + get "/", UserController, :show + get "/following", FollowController, :following + get "/followers", FollowController, :followers + post "/inbox", InboxController, :inbox + get "/outbox", OutboxController, :outbox + get "/statuses/:id", NoteController, :show + end + + scope "/@:username" do + get "/", UserController, :show + get "/following", FollowController, :following + get "/followers", FollowController, :followers + post "/inbox", InboxController, :inbox + get "/outbox", OutboxController, :outbox + get "/:id", NoteController, :show + end end # Other scopes may use custom stacks. From 894866ca032856d798e5aa31588bab5a1ec737dd Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 17 Jun 2025 12:06:36 +0200 Subject: [PATCH 065/144] Update --- lib/nulla/activitypub.ex | 199 +++++++++--------- lib/nulla/httpsignature.ex | 5 + lib/nulla/models/activity.ex | 15 +- lib/nulla/models/actor.ex | 15 +- lib/nulla/models/follow.ex | 11 +- lib/nulla/models/note.ex | 31 +-- lib/nulla/models/relation.ex | 30 +++ lib/nulla/models/user.ex | 3 - lib/nulla_web/controllers/actor_controller.ex | 43 ++++ lib/nulla_web/controllers/auth_controller.ex | 12 ++ .../controllers/follow_controller.ex | 30 +-- lib/nulla_web/controllers/inbox_controller.ex | 48 ++++- .../controllers/nodeinfo_controller.ex | 1 - .../controllers/outbox_controller.ex | 14 +- lib/nulla_web/controllers/user_controller.ex | 33 --- .../controllers/webfinger_controller.ex | 9 +- lib/nulla_web/router.ex | 4 +- .../20250615130714_create_actors.exs | 14 +- .../20250615131431_create_notes.exs | 4 +- .../20250615131836_create_follows.exs | 8 +- .../20250615131856_create_activities.exs | 5 +- .../20250617091354_create_relations.exs | 23 ++ 22 files changed, 344 insertions(+), 213 deletions(-) create mode 100644 lib/nulla/httpsignature.ex create mode 100644 lib/nulla/models/relation.ex create mode 100644 lib/nulla_web/controllers/actor_controller.ex create mode 100644 lib/nulla_web/controllers/auth_controller.ex create mode 100644 priv/repo/migrations/20250617091354_create_relations.exs diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index cbf5cfe..16183a4 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -25,63 +25,35 @@ defmodule Nulla.ActivityPub do ] end - @spec user(String.t(), Nulla.Models.User.t()) :: Jason.OrderedObject.t() - def user(domain, user) do + @spec actor(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + def actor(actor) do Jason.OrderedObject.new( "@context": context(), - id: "https://#{domain}/@#{user.username}", - type: "Person", - following: "https://#{domain}/@#{user.username}/following", - followers: "https://#{domain}/@#{user.username}/followers", - inbox: "https://#{domain}/@#{user.username}/inbox", - outbox: "https://#{domain}/@#{user.username}/outbox", - featured: "https://#{domain}/@#{user.username}/collections/featured", - preferredUsername: user.username, - name: user.realname, - summary: user.bio, - url: "https://#{domain}/@#{user.username}", - manuallyApprovesFollowers: user.follow_approval, - discoverable: user.is_discoverable, - indexable: user.is_indexable, - published: DateTime.to_iso8601(user.inserted_at), - memorial: user.is_memorial, - publicKey: - Jason.OrderedObject.new( - id: "https://#{domain}/@#{user.username}#main-key", - owner: "https://#{domain}/@#{user.username}", - publicKeyPem: user.public_key - ), - tag: - Enum.map(user.tags, fn tag -> - Jason.OrderedObject.new( - type: "Hashtag", - href: "https://#{domain}/tags/#{tag}", - name: "##{tag}" - ) - end), - attachment: - Enum.map(user.fields, fn {name, value} -> - Jason.OrderedObject.new( - type: "PropertyValue", - name: name, - value: value - ) - end), - endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox"), - icon: - Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.avatar), - url: "https://#{domain}/files/#{user.avatar}" - ), - image: - Jason.OrderedObject.new( - type: "Image", - mediaType: MIME.from_path(user.banner), - url: "https://#{domain}/files/#{user.banner}" - ), - "vcard:bday": user.birthday, - "vcard:Address": user.location + id: actor.ap_id, + type: actor.type, + following: actor.following, + followers: actor.followers, + inbox: actor.inbox, + outbox: actor.outbox, + featured: actor.featured, + featuredTags: actor.featuredTags, + preferredUsername: actor.preferredUsername, + name: actor.name, + summary: actor.summary, + url: actor.url, + manuallyApprovesFollowers: actor.manuallyApprovesFollowers, + discoverable: actor.discoverable, + indexable: actor.indexable, + published: DateTime.to_iso8601(actor.published), + memorial: actor.memorial, + publicKey: actor.publicKey, + tag: actor.tag, + attachment: actor.attachment, + endpoints: actor.endpoints, + icon: actor.icon, + image: actor.image, + "vcard:bday": actor.vcard_bday, + "vcard:Address": actor.vcard_Address ) end @@ -110,18 +82,18 @@ defmodule Nulla.ActivityPub do "https://www.w3.org/ns/activitystreams", Jason.OrderedObject.new(sensitive: "as:sensitive") ], - id: "https://#{domain}/@#{note.user.username}/#{note.id}", + id: "https://#{domain}/users/#{note.actor.preferredUsername}/statuses/#{note.id}", type: "Note", summary: nil, inReplyTo: nil, published: note.inserted_at, - url: "https://#{domain}/@#{note.user.username}/#{note.id}", - attributedTo: "https://#{domain}/@#{note.user.username}", + url: "https://#{domain}/@#{note.actor.preferredUsername}/#{note.id}", + attributedTo: "https://#{domain}/users/#{note.actor.preferredUsername}", to: [ "https://www.w3.org/ns/activitystreams#Public" ], cc: [ - "https://#{domain}/@#{note.user.username}/followers" + "https://#{domain}/users/#{note.actor.preferredUsername}/followers" ], sensetive: false, content: note.content, @@ -141,35 +113,35 @@ defmodule Nulla.ActivityPub do ) end - @spec following(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() - def following(domain, user, total) do + @spec following(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(domain, actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/following", + id: "https://#{domain}/users/#{actor.preferredUsername}/following", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{user.username}/following?page=1" + first: "https://#{domain}/users/#{actor.preferredUsername}/following?page=1" ) end @spec following( String.t(), - Nulla.Models.User.t(), + Nulla.Models.Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t() ) :: Jason.OrderedObject.t() - def following(domain, user, total, following_list, page, offset) + def following(domain, actor, total, following_list, page, offset) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/following?page=#{page}", + id: "https://#{domain}/@#{actor.preferredUsername}/following?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/@#{user.username}/following?page=#{page + 1}", - prev: "https://#{domain}/@#{user.username}/following?page=#{page - 1}", - partOf: "https://#{domain}/@#{user.username}/following", + next: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}", + prev: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}", + partOf: "https://#{domain}/users/#{actor.preferredUsername}/following", orderedItems: following_list ] @@ -192,35 +164,35 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec followers(String.t(), Nulla.Models.User.t(), Integer.t()) :: Jason.OrderedObject.t() - def followers(domain, user, total) do + @spec followers(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(domain, actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/followers", + id: "https://#{domain}/users/#{actor.preferredUsername}/followers", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{user.username}/followers?page=1" + first: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=1" ) end @spec followers( String.t(), - Nulla.Models.User.t(), + Nulla.Models.Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t() ) :: Jason.OrderedObject.t() - def followers(domain, user, total, followers_list, page, offset) + def followers(domain, actor, total, followers_list, page, offset) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{user.username}/followers?page=#{page}", + id: "https://#{domain}/users#{actor.preferredUsername}/followers?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/@#{user.username}/followers?page=#{page + 1}", - prev: "https://#{domain}/@#{user.username}/followers?page=#{page - 1}", - partOf: "https://#{domain}/@#{user.username}/followers", + next: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}", + prev: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}", + partOf: "https://#{domain}/users/#{actor.preferredUsername}/followers", orderedItems: followers_list ] @@ -243,15 +215,29 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec webfinger(String.t(), String.t(), String.t()) :: Jason.OrderedObject.t() - def webfinger(domain, username, resource) do + @spec webfinger(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + def webfinger(actor) do Jason.OrderedObject.new( - subject: resource, + subject: "#{actor.preferredUsername}@#{actor.domain}", + aliases: [ + "https://#{actor.domain}/@#{actor.preferredUsername}", + "https://#{actor.domain}/users/#{actor.preferredUsername}" + ], links: [ + Jason.OrderedObject.new( + rel: "http://webfinger.net/rel/profile-page", + type: "text/html", + href: "https://#{actor.domain}/users/#{actor.preferredUsername}" + ), Jason.OrderedObject.new( rel: "self", type: "application/activity+json", - href: "https://#{domain}/@#{username}" + href: "https://#{actor.domain}/users/#{actor.preferredUsername}" + ), + Jason.OrderedObject.new( + rel: "http://webfinger.net/rel/avatar", + type: actor.icon.mediaType, + href: actor.icon.url ) ] ) @@ -309,11 +295,11 @@ defmodule Nulla.ActivityPub do def outbox(domain, username, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{username}/outbox", + id: "https://#{domain}/users/#{username}/outbox", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/@#{username}/outbox?page=true", - last: "https://#{domain}/@#{username}/outbox?min_id=0&page=true" + first: "https://#{domain}/users/#{username}/outbox?page=true", + last: "https://#{domain}/users/#{username}/outbox?min_id=0&page=true" ) end @@ -328,32 +314,49 @@ defmodule Nulla.ActivityPub do Hashtag: "as:Hashtag" ) ], - id: "https://#{domain}/@#{username}/outbox?page=true", + id: "https://#{domain}/users/#{username}/outbox?page=true", type: "OrderedCollectionPage", - next: "https://#{domain}/@#{username}/outbox?max_id=#{max_id}&page=true", - prev: "https://#{domain}/@#{username}/outbox?min_id=#{min_id}&page=true", - partOf: "https://#{domain}/@#{username}/outbox", + next: "https://#{domain}/users/#{username}/outbox?max_id=#{max_id}&page=true", + prev: "https://#{domain}/users/#{username}/outbox?min_id=#{min_id}&page=true", + partOf: "https://#{domain}/users/#{username}/outbox", orderedItems: items ) end - @spec render_activity(String.t(), Note.t()) :: Jason.OrderedObject.t() - def render_activity(domain, note) do + @spec activity_note(Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + def activity_note(note) do Jason.OrderedObject.new( - id: "https://#{domain}/@#{note.user.username}/#{note.id}/activity", + id: + "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}/#{note.id}/activity", type: "Create", - actor: "https://#{domain}/@#{note.user.username}", + actor: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}", published: note.inserted_at |> DateTime.to_iso8601(), - to: ["https://www.w3.org/ns/activitystreams#Public"], + to: [ + "https://www.w3.org/ns/activitystreams#Public" + ], object: Jason.OrderedObject.new( - id: "https://#{domain}/@#{note.user.username}/#{note.id}", + id: + "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}/statuses/#{note.id}", type: "Note", content: note.content, published: note.inserted_at |> DateTime.to_iso8601(), - attributedTo: "https://#{domain}/@#{note.user.username}", - to: ["https://www.w3.org/ns/activitystreams#Public"] + attributedTo: "https://#{note.actor.domain}/users/#{note.actor.preferredUsername}", + to: [ + "https://www.w3.org/ns/activitystreams#Public" + ] ) ) end + + @spec follow_accept(Nulla.Models.Activity.t()) :: Jason.OrderedObject.t() + def follow_accept(activity) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object + ) + end end diff --git a/lib/nulla/httpsignature.ex b/lib/nulla/httpsignature.ex new file mode 100644 index 0000000..8831606 --- /dev/null +++ b/lib/nulla/httpsignature.ex @@ -0,0 +1,5 @@ +defmodule Nulla.HTTPSignature do + def verify(_conn, _actor) do + :ok + end +end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index e41b879..cfd208e 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -1,9 +1,11 @@ defmodule Nulla.Models.Activity do use Ecto.Schema import Ecto.Changeset + alias Nulla.SnowFlake @primary_key {:id, :integer, autogenerate: false} schema "activities" do + field :ap_id, :string field :type, :string field :actor, :string field :object, :map @@ -15,8 +17,17 @@ defmodule Nulla.Models.Activity do @doc false def changeset(activity, attrs) do activity - |> cast(attrs, [:type, :actor, :object, :to]) - |> validate_required([:type, :actor, :object]) + |> cast(attrs, [:ap_id, :type, :actor, :object, :to]) + |> validate_required([:ap_id, :type, :actor, :object]) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end + + def create_activity(attrs) do + id = Snowflake.next_id() + + %__MODULE__{} + |> __MODULE__.changeset(attrs) + |> Ecto.Changeset.put_change(:id, id) + |> Repo.insert() + end end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index 653c37f..ace64be 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -1,14 +1,14 @@ defmodule Nulla.Models.Actor do use Ecto.Schema import Ecto.Changeset - import Ecto.Query alias Nulla.Repo alias Nulla.Snowflake - alias Nulla.Models.User alias Nulla.Models.Note @primary_key {:id, :integer, autogenerate: false} schema "actors" do + field :domain, :string + field :ap_id, :string field :type, :string field :following, :string field :followers, :string @@ -34,7 +34,6 @@ defmodule Nulla.Models.Actor do field :vcard_bday, :date field :vcard_Address, :string - has_one :user, User has_many :notes, Note has_many :media_attachments, through: [:notes, :media_attachments] end @@ -44,6 +43,8 @@ defmodule Nulla.Models.Actor do actor |> cast(attrs, [ :id, + :domain, + :ap_id, :type, :following, :followers, @@ -71,6 +72,8 @@ defmodule Nulla.Models.Actor do ]) |> validate_required([ :id, + :domain, + :ap_id, :type, :following, :followers, @@ -98,7 +101,7 @@ defmodule Nulla.Models.Actor do ]) end - def create_user(attrs) when is_map(attrs) do + def create_actor(attrs) when is_map(attrs) do id = Snowflake.next_id() %__MODULE__{} @@ -106,4 +109,8 @@ defmodule Nulla.Models.Actor do |> Ecto.Changeset.put_change(:id, id) |> Repo.insert() end + + def get_actor(username, domain) do + Repo.get_by(__MODULE__, preferredUsername: username, domain: domain) + end end diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex index bcd92f7..b976f59 100644 --- a/lib/nulla/models/follow.ex +++ b/lib/nulla/models/follow.ex @@ -1,13 +1,14 @@ defmodule Nulla.Models.Follow do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo alias Nulla.Snowflake - alias Nulla.Models.Follow + alias Nulla.Models.Actor @primary_key {:id, :integer, autogenerate: false} schema "follows" do - belongs_to :user, Nulla.Models.User - belongs_to :target, Nulla.Models.User + belongs_to :follower, Actor + belongs_to :followed, Actor timestamps() end @@ -23,8 +24,8 @@ defmodule Nulla.Models.Follow do def create_follow(attrs) do id = Snowflake.next_id() - %Follow{} - |> Follow.changeset(attrs) + %__MODULE__{} + |> __MODULE__.changeset(attrs) |> Ecto.Changeset.put_change(:id, id) |> Repo.insert() end diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 7a7a20f..51637e8 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -3,7 +3,8 @@ defmodule Nulla.Models.Note do import Ecto.Changeset import Ecto.Query alias Nulla.Repo - alias Nulla.Models.Note + alias Nulla.Models.Actor + alias Nulla.Models.MediaAttachment @primary_key {:id, :integer, autogenerate: false} schema "notes" do @@ -17,8 +18,8 @@ defmodule Nulla.Models.Note do field :language, :string field :in_reply_to, :string - belongs_to :user, Nulla.Models.User - has_many :media_attachments, Nulla.Models.MediaAttachment + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment timestamps(type: :utc_datetime) end @@ -26,32 +27,32 @@ defmodule Nulla.Models.Note do @doc false def changeset(note, attrs) do note - |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) - |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :user_id]) + |> cast(attrs, [:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) + |> validate_required([:content, :visibility, :sensitive, :language, :in_reply_to, :actor_id]) end - def get_note!(id), do: Repo.get!(Note, id) + def get_note!(id), do: Repo.get!(__MODULE__, id) - def get_latest_notes(user_id, limit \\ 20) do - from(n in Note, - where: n.user_id == ^user_id, + def get_latest_notes(actor_id, limit \\ 20) do + from(n in __MODULE__, + where: n.actor_id == ^actor_id, order_by: [desc: n.inserted_at], limit: ^limit ) |> Repo.all() end - def get_before_notes(user_id, max_id, limit \\ 20) do - from(n in Note, - where: n.user_id == ^user_id and n.id < ^max_id, + def get_before_notes(actor_id, max_id, limit \\ 20) do + from(n in __MODULE__, + where: n.actor_id == ^actor_id and n.id < ^max_id, order_by: [desc: n.inserted_at], limit: ^limit ) - |> Nulla.Repo.all() + |> Repo.all() end - def get_total_notes_count(user_id) do - from(n in Note, where: n.user_id == ^user_id) + def get_total_notes_count(actor_id) do + from(n in __MODULE__, where: n.actor_id == ^actor_id) |> Repo.aggregate(:count, :id) end end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex new file mode 100644 index 0000000..4b80963 --- /dev/null +++ b/lib/nulla/models/relation.ex @@ -0,0 +1,30 @@ +defmodule Nulla.Models.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Nulla.Models.Actor + alias Nulla.Models.Activity + + @primary_key {:id, :integer, autogenerate: false} + schema "relations" do + field :type, :string + field :status, :string + + belongs_to :source, Actor, foreign_key: :source_id, type: :integer + belongs_to :target, Actor, foreign_key: :target_id, type: :integer + belongs_to :activity, Activity, foreign_key: :activity_id, type: :integer + + timestamps() + end + + def changeset(relation, attrs) do + relation + |> cast(attrs, [:id, :source_id, :target_id, :type, :status, :activity_id]) + |> validate_required([:id, :source_id, :target_id, :type]) + |> validate_inclusion(:type, ~w(follow block mute friend_request)) + |> validate_inclusion(:status, ~w(pending accepted rejected active)) + |> foreign_key_constraint(:source_id) + |> foreign_key_constraint(:target_id) + |> foreign_key_constraint(:activity_id) + |> unique_constraint([:source_id, :target_id, :type]) + end +end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 4328761..2d7742e 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -3,7 +3,6 @@ defmodule Nulla.Models.User do import Ecto.Changeset import Ecto.Query alias Nulla.Repo - alias Nulla.Snowflake alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Session @@ -48,8 +47,6 @@ defmodule Nulla.Models.User do def get_user_by_username(username), do: Repo.get_by(User, username: username) - def get_user_by_username!(username), do: Repo.get_by!(User, username: username) - def get_user_by_username_and_domain(username, domain) do from(u in User, where: u.username == ^username and u.domain == ^domain diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex new file mode 100644 index 0000000..c79ae22 --- /dev/null +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActorController do + use NullaWeb, :controller + alias Nulla.ActivityPub + alias Nulla.Utils + alias Nulla.Models.Actor + alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings + + def show(conn, %{"username" => username}) do + accept = List.first(get_req_header(conn, "accept")) + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + + case Actor.get_actor(username, domain) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + %Actor{} = actor -> + if accept in ["application/activity+json", "application/ld+json"] do + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) + else + notes = Note.get_latest_notes(actor.id) + following = Utils.count_following_by_username!(actor.preferredUsername) + followers = Utils.count_followers_by_username!(actor.preferredUsername) + + render( + conn, + :show, + domain: domain, + actor: actor, + notes: notes, + following: following, + followers: followers, + layout: false + ) + end + end + end +end diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex new file mode 100644 index 0000000..3edddb1 --- /dev/null +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -0,0 +1,12 @@ +defmodule NullaWeb.AuthController do + use NullaWeb, :controller + + def sign_in do + end + + def sign_out do + end + + def sign_up do + end +end diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index 6f8a76a..dce9ed9 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -2,15 +2,15 @@ defmodule NullaWeb.FollowController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Utils - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings def following(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain offset = instance_settings.offset - user = User.get_user_by_username!(username) - total = Utils.count_following_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_following_by_username!(actor.preferredUsername) page = case Integer.parse(page_param) do @@ -18,30 +18,30 @@ defmodule NullaWeb.FollowController do _ -> 1 end - following_list = Utils.get_following_users_by_username!(user.username, page) + following_list = Utils.get_following_users_by_username!(actor.preferredUsername, page) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total, following_list, page, offset)) + |> json(ActivityPub.following(domain, actor, total, following_list, page, offset)) end def following(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Utils.count_following_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_following_by_username!(actor.preferredUsername) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.following(domain, user, total)) + |> json(ActivityPub.following(domain, actor, total)) end def followers(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain offset = instance_settings.offset - user = User.get_user_by_username!(username) - total = Utils.count_followers_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_followers_by_username!(actor.preferredUsername) page = case Integer.parse(page_param) do @@ -49,21 +49,21 @@ defmodule NullaWeb.FollowController do _ -> 1 end - followers_list = Utils.get_followers_by_username!(user.username, page) + followers_list = Utils.get_followers_by_username!(actor.preferredUsername, page) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total, followers_list, page, offset)) + |> json(ActivityPub.followers(domain, actor, total, followers_list, page, offset)) end def followers(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Utils.count_followers_by_username!(user.username) + actor = Actor.get_actor(username, domain) + total = Utils.count_followers_by_username!(actor.preferredUsername) conn |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.followers(domain, user, total)) + |> json(ActivityPub.followers(domain, actor, total)) end end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index d0ba0e3..fd0d7a8 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,19 +1,49 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller - alias Nulla.Models.Follow + alias Nulla.HTTPSignature + alias Nulla.ActivityPub alias Nulla.Utils + alias Nulla.Models.Actor + alias Nulla.Models.Relation def inbox( conn, - %{"type" => "Follow", "actor" => actor_uri, "object" => target_uri} = activity + %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} ) do - with {:ok, target_user} <- Utils.resolve_local_actor(target_uri), - {:ok, remote_actor} <- Utils.fetch_remote_actor(actor_uri), - :ok <- HTTPSignature.verify(conn, remote_actor), - remote_user <- Follow.create_remote_user(remote_actor), - follow <- Follow.create_follow(%{user: remote_user, target: target_user}), - :ok <- Utils.send_accept_activity(remote_actor, target_user, follow, activity) do - json(conn, %{"status" => "Follow accepted"}) + with {:ok, target_actor} <- Utils.resolve_local_actor(target_uri), + {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), + :ok <- HTTPSignature.verify(conn, remote_actor_json), + remote_actor <- + Actor.create_actor( + remote_actor_json + |> Map.put("ap_id", remote_actor_json["id"]) + |> Map.delete("id") + |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) + ), + follow_activity <- + Activity.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: actor_uri, + object: target_uri + }), + accept_activity <- + Activity.create_activity(%{ + type: "Accept", + actor: target_uri, + object: follow_activity + }), + relation <- Relation.create_relation(%{ + id: 1, + follower: remote_actor.id, + followed: target_actor.id + }) do + conn + |> put_resp_content_type("application/activity+json") + |> send_resp( + 200, + Jason.encode!(ActivityPub.follow_accept(accept_activity)) + ) else error -> IO.inspect(error, label: "Follow error") diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/nodeinfo_controller.ex index 46a3bed..ff9c113 100644 --- a/lib/nulla_web/controllers/nodeinfo_controller.ex +++ b/lib/nulla_web/controllers/nodeinfo_controller.ex @@ -1,6 +1,5 @@ defmodule NullaWeb.NodeinfoController do use NullaWeb, :controller - alias Nulla.Repo alias Nulla.ActivityPub alias Nulla.Models.User alias Nulla.Models.InstanceSettings diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index b62214b..505a1fb 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -1,7 +1,7 @@ defmodule NullaWeb.OutboxController do use NullaWeb, :controller alias Nulla.ActivityPub - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.Note alias Nulla.Models.InstanceSettings @@ -10,17 +10,17 @@ defmodule NullaWeb.OutboxController do "true" -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) + actor = Actor.get_actor(username, domain) max_id = params["max_id"] && String.to_integer(params["max_id"]) notes = if max_id do - Note.get_before_notes(user.id, max_id) + Note.get_before_notes(actor.id, max_id) else - Note.get_latest_notes(user.id) + Note.get_latest_notes(actor.id) end - items = Enum.map(notes, &ActivityPub.render_activity(&1, domain)) + items = Enum.map(notes, &ActivityPub.activity_note(&1)) next_max_id = case List.last(notes) do @@ -44,8 +44,8 @@ defmodule NullaWeb.OutboxController do _ -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - user = User.get_user_by_username!(username) - total = Note.get_total_notes_count(user.id) + actor = Actor.get_actor(username, domain) + total = Note.get_total_notes_count(actor.id) conn |> put_resp_content_type("application/activity+json") diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex index 9263318..10d530d 100644 --- a/lib/nulla_web/controllers/user_controller.ex +++ b/lib/nulla_web/controllers/user_controller.ex @@ -1,36 +1,3 @@ defmodule NullaWeb.UserController do use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Utils - alias Nulla.Models.User - alias Nulla.Models.Note - alias Nulla.Models.InstanceSettings - - def show(conn, %{"username" => username}) do - accept = List.first(get_req_header(conn, "accept")) - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - user = User.get_user_by_username!(username) - notes = Note.get_notes(user.id) - - if accept in ["application/activity+json", "application/ld+json"] do - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.user(domain, user))) - else - following = Utils.count_following_by_username!(user.username) - followers = Utils.count_followers_by_username!(user.username) - - render( - conn, - :show, - domain: domain, - user: user, - notes: notes, - following: following, - followers: followers, - layout: false - ) - end - end end diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index e11afeb..cc7854f 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -1,24 +1,23 @@ defmodule NullaWeb.WebfingerController do use NullaWeb, :controller - alias Nulla.Repo alias Nulla.ActivityPub - alias Nulla.Models.User + alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings def index(conn, %{"resource" => resource}) do case Regex.run(~r/^acct:([^@]+)@(.+)$/, resource) do [_, username, domain] -> - case User.get_user_by_username(username) do + case Actor.get_actor(username, domain) do nil -> conn |> put_status(:not_found) |> json(%{error: "Not Found"}) - user -> + %Actor{} = actor -> instance_settings = InstanceSettings.get_instance_settings!() if domain == instance_settings.domain do - json(conn, ActivityPub.webfinger(domain, username, resource)) + json(conn, ActivityPub.webfinger(actor)) else conn |> put_status(:not_found) diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index edaaf09..ed3b543 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -29,7 +29,7 @@ defmodule NullaWeb.Router do end scope "/users/:username" do - get "/", UserController, :show + get "/", ActorController, :show get "/following", FollowController, :following get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox @@ -38,7 +38,7 @@ defmodule NullaWeb.Router do end scope "/@:username" do - get "/", UserController, :show + get "/", ActorController, :show get "/following", FollowController, :following get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs index 355f3e0..d94ca47 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -4,14 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActors do def change do create table(:actors, primary_key: false) do add :id, :bigint, primary_key: true - add :type, :string - add :following, :string - add :followers, :string - add :inbox, :string - add :outbox, :string + add :domain, :string + add :ap_id, :string, null: false + add :type, :string, null: false + add :following, :string, null: false + add :followers, :string, null: false + add :inbox, :string, null: false + add :outbox, :string, null: false add :featured, :string add :featuredTags, :string - add :preferredUsername, :string + add :preferredUsername, :string, null: false add :name, :string add :summary, :string add :url, :string diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index 975fd6f..a930b14 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -9,11 +9,11 @@ defmodule Nulla.Repo.Migrations.CreateNotes do add :sensitive, :boolean, default: false add :language, :string add :in_reply_to, :string - add :user_id, references(:users, on_delete: :delete_all) + add :actor_id, references(:actors, on_delete: :delete_all) timestamps(type: :utc_datetime) end - create index(:notes, [:user_id]) + create index(:notes, [:actor_id]) end end diff --git a/priv/repo/migrations/20250615131836_create_follows.exs b/priv/repo/migrations/20250615131836_create_follows.exs index b39244e..c12b6ba 100644 --- a/priv/repo/migrations/20250615131836_create_follows.exs +++ b/priv/repo/migrations/20250615131836_create_follows.exs @@ -4,13 +4,13 @@ defmodule Nulla.Repo.Migrations.CreateFollows do def change do create table(:follows, primary_key: false) do add :id, :bigint, primary_key: true - add :user_id, references(:users, on_delete: :delete_all), null: false - add :target_id, references(:users, on_delete: :delete_all), null: false + add :follower_id, references(:actors, on_delete: :delete_all), null: false + add :following_id, references(:actors, on_delete: :delete_all), null: false timestamps() end - create unique_index(:follows, [:user_id, :target_id]) - create index(:follows, [:target_id]) + create unique_index(:follows, [:follower_id, :following_id]) + create index(:follows, [:following_id]) end end diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index 25addfd..b07c822 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -4,15 +4,16 @@ defmodule Nulla.Repo.Migrations.CreateActivities do def change do create table(:activities, primary_key: false) do add :id, :bigint, primary_key: true + add :ap_id, :string, null: false add :type, :string, null: false - add :actor, :string, null: false + add :actor_id, references(:actors, type: :bigint, on_delete: :nothing), null: false add :object, :map, null: false add :to, {:array, :string}, default: [] timestamps() end - create index(:activities, [:actor]) create index(:activities, [:type]) + create index(:activities, [:actor_id]) end end diff --git a/priv/repo/migrations/20250617091354_create_relations.exs b/priv/repo/migrations/20250617091354_create_relations.exs new file mode 100644 index 0000000..b4c66bf --- /dev/null +++ b/priv/repo/migrations/20250617091354_create_relations.exs @@ -0,0 +1,23 @@ +defmodule Nulla.Repo.Migrations.CreateActorRelations do + use Ecto.Migration + + def change do + create table(:relations, primary_key: false) do + add :id, :bigint, primary_key: true + add :source_id, :bigint, null: false + add :target_id, :bigint, null: false + add :type, :string, null: false + add :status, :string, null: false + add :activity_id, :bigint + + timestamps() + end + + create index(:relations, [:source_id]) + create index(:relations, [:target_id]) + create index(:relations, [:type]) + create index(:relations, [:activity_id]) + + create unique_index(:relations, [:source_id, :target_id, :type]) + end +end From 10756907dcc95ddfdc671c7394be30d0e68c97f2 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 17 Jun 2025 12:56:08 +0200 Subject: [PATCH 066/144] Add actor/show.html.heex --- .../templates/{user => actor}/show.html.heex | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) rename lib/nulla_web/components/templates/{user => actor}/show.html.heex (75%) diff --git a/lib/nulla_web/components/templates/user/show.html.heex b/lib/nulla_web/components/templates/actor/show.html.heex similarity index 75% rename from lib/nulla_web/components/templates/user/show.html.heex rename to lib/nulla_web/components/templates/actor/show.html.heex index 5a41a2f..fb5fedf 100644 --- a/lib/nulla_web/components/templates/user/show.html.heex +++ b/lib/nulla_web/components/templates/actor/show.html.heex @@ -16,10 +16,10 @@
- +
- {@user.realname} - @{@user.username}@{@domain} + {@actor.name} + @{@actor.preferredUsername}@{@actor.domain}
-

{@user.bio}

+

{@actor.summary}

- <%= if @user.location do %> + <%= if @actor.vcard_Address do %>
<.icon name="hero-map-pin" class="mt-0.5 h-5 w-5 flex-none" />
-
{@user.location}
+
{@actor.vcard_Address}
<% end %> - <%= if @user.birthday do %> + <%= if @actor.vcard_bday do %>
<.icon name="hero-cake" class="mt-0.5 h-5 w-5 flex-none" />
-
{format_birthdate(@user.birthday)}
+
{format_birthdate(@actor.vcard_bday)}
<% end %>
<.icon name="hero-calendar" class="mt-0.5 h-5 w-5 flex-none" />
-
{format_registration_date(@user.inserted_at)}
+
{format_registration_date(@actor.published)}
- <%= if @user.fields do %> + <%= if @actor.attachment do %>
- <%= for {key, value} <- @user.fields do %> -
{key}
+ <%= for %{"type" => "PropertyValue", "name" => name, "value" => value} <- @actor.attachment do %> +
{name}
<%= if Regex.match?(~r{://}, value) do %> {Regex.replace(~r{^\w+://}, value, "")} @@ -66,30 +66,30 @@
<% end %>
<%= for note <- @notes do %>
- +
- {@user.realname} + {@actor.name} - @{@user.username}@{@domain} + @{@actor.preferredUsername}@{@actor.domain}
From f43b4bd038085c5d518f32ecad2a81f90c869e2e Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 17 Jun 2025 12:56:46 +0200 Subject: [PATCH 067/144] Remove timex --- lib/nulla_web/components/templates.ex | 101 ++++++++++-------- lib/nulla_web/controllers/actor_controller.ex | 5 +- mix.exs | 1 - 3 files changed, 60 insertions(+), 47 deletions(-) diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index acec111..817fa17 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -1,77 +1,92 @@ -defmodule NullaWeb.UserHTML do +defmodule NullaWeb.ActorHTML do use NullaWeb, :html - embed_templates "templates/user/*" + embed_templates "templates/actor/*" def format_birthdate(date) do formatted = Date.to_string(date) |> String.replace("-", "/") - age = Timex.diff(Timex.today(), date, :years) + + today = Date.utc_today() + + age = + today.year - date.year - + if {today.month, today.day} < {date.month, date.day}, do: 1, else: 0 + "#{formatted} (#{age} years old)" end def format_registration_date(date) do - now = Timex.now() + now = Date.utc_today() formatted = Date.to_string(date) |> String.replace("-", "/") - - diff = Timex.diff(now, date, :days) - - relative = - cond do - diff == 0 -> - "today" - - diff == 1 -> - "1 day ago" - - diff < 30 -> - "#{diff} days ago" - - diff < 365 -> - months = Timex.diff(now, date, :months) - if months == 1, do: "1 month ago", else: "#{months} months ago" - - true -> - years = Timex.diff(now, date, :years) - if years == 1, do: "1 year ago", else: "#{years} years ago" - end - - "#{formatted} (#{relative})" + + diff_days = Date.diff(now, date) + + cond do + diff_days == 0 -> + "#{formatted} (today)" + + diff_days == 1 -> + "#{formatted} (1 day ago)" + + diff_days < 30 -> + "#{formatted} (#{diff_days} days ago)" + + diff_days < 365 -> + year_diff = now.year - date.year + month_diff = now.month - date.month + day_correction = if now.day < date.day, do: -1, else: 0 + months = year_diff * 12 + month_diff + day_correction + if months == 1 do + "#{formatted} (1 month ago)" + else + "#{formatted} (#{months} months ago)" + end + + true -> + year_diff = now.year - date.year + years = if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff + if years == 1 do + "#{formatted} (1 year ago)" + else + "#{formatted} (#{years} years ago)" + end + end end def format_note_datetime(datetime) do - Timex.format!(datetime, "{0D} {Mfull} {YYYY}, {h24}:{m}", :strftime) + Calendar.strftime(datetime, "%d %B %Y, %H:%M") end def format_note_datetime_diff(datetime) do - now = Timex.now() - diff = Timex.diff(now, datetime, :seconds) + now = DateTime.utc_now() + diff_seconds = DateTime.diff(now, datetime) cond do - diff < 60 -> + diff_seconds < 60 -> "now" - diff < 3600 -> - minutes = div(diff, 60) + diff_seconds < 3600 -> + minutes = div(diff_seconds, 60) "#{minutes}m ago" - diff < 86400 -> - hours = div(diff, 3600) + diff_seconds < 86400 -> + hours = div(diff_seconds, 3600) "#{hours}h ago" - diff < 518_400 -> - days = div(diff, 86400) + diff_seconds < 604_800 -> + days = div(diff_seconds, 86400) "#{days}d ago" - diff < 2_419_200 -> - weeks = div(diff, 604_800) + diff_seconds < 2_592_000 -> + weeks = div(diff_seconds, 604_800) "#{weeks}w ago" - diff < 28_512_000 -> - months = Timex.diff(now, datetime, :months) + diff_seconds < 31_536_000 -> + months = div(diff_seconds, 2_592_000) "#{months}mo ago" true -> - years = Timex.diff(now, datetime, :years) + years = div(diff_seconds, 31_536_000) "#{years}y ago" end end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index c79ae22..e3c55ea 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -24,13 +24,12 @@ defmodule NullaWeb.ActorController do |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) else notes = Note.get_latest_notes(actor.id) - following = Utils.count_following_by_username!(actor.preferredUsername) - followers = Utils.count_followers_by_username!(actor.preferredUsername) + following = 0 + followers = 0 render( conn, :show, - domain: domain, actor: actor, notes: notes, following: following, diff --git a/mix.exs b/mix.exs index da0bd5b..09ceeec 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,6 @@ defmodule Nulla.MixProject do {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, - {:timex, "~> 3.7"}, {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.1", From 3a57d743571ffa899cdaeaa89396d75d20d882ed Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 18 Jun 2025 06:35:58 +0200 Subject: [PATCH 068/144] Add relations --- lib/nulla/models/activity.ex | 5 +- lib/nulla/models/actor.ex | 2 +- lib/nulla/models/follow.ex | 32 ---------- lib/nulla/models/media_attachment.ex | 3 +- lib/nulla/models/moderation_log.ex | 3 +- lib/nulla/models/relation.ex | 60 ++++++++++++++----- lib/nulla/models/session.ex | 3 +- lib/nulla/models/user.ex | 2 +- lib/nulla_web/controllers/actor_controller.ex | 1 - lib/nulla_web/controllers/inbox_controller.ex | 11 ++-- .../20250615131836_create_follows.exs | 16 ----- .../20250617091354_create_relations.exs | 28 +++++---- 12 files changed, 81 insertions(+), 85 deletions(-) delete mode 100644 lib/nulla/models/follow.ex delete mode 100644 priv/repo/migrations/20250615131836_create_follows.exs diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index cfd208e..f2649bf 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -1,7 +1,8 @@ defmodule Nulla.Models.Activity do use Ecto.Schema import Ecto.Changeset - alias Nulla.SnowFlake + alias Nulla.Repo + alias Nulla.Snowflake @primary_key {:id, :integer, autogenerate: false} schema "activities" do @@ -27,7 +28,7 @@ defmodule Nulla.Models.Activity do %__MODULE__{} |> __MODULE__.changeset(attrs) - |> Ecto.Changeset.put_change(:id, id) + |> Changeset.put_change(:id, id) |> Repo.insert() end end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index ace64be..d181c38 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -106,7 +106,7 @@ defmodule Nulla.Models.Actor do %__MODULE__{} |> changeset(attrs) - |> Ecto.Changeset.put_change(:id, id) + |> Changeset.put_change(:id, id) |> Repo.insert() end diff --git a/lib/nulla/models/follow.ex b/lib/nulla/models/follow.ex deleted file mode 100644 index b976f59..0000000 --- a/lib/nulla/models/follow.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Nulla.Models.Follow do - use Ecto.Schema - import Ecto.Changeset - alias Nulla.Repo - alias Nulla.Snowflake - alias Nulla.Models.Actor - - @primary_key {:id, :integer, autogenerate: false} - schema "follows" do - belongs_to :follower, Actor - belongs_to :followed, Actor - - timestamps() - end - - @doc false - def changeset(follow, attrs) do - follow - |> cast(attrs, [:user_id, :target_id]) - |> validate_required([:user_id, :target_id]) - |> unique_constraint([:user_id, :target_id]) - end - - def create_follow(attrs) do - id = Snowflake.next_id() - - %__MODULE__{} - |> __MODULE__.changeset(attrs) - |> Ecto.Changeset.put_change(:id, id) - |> Repo.insert() - end -end diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/models/media_attachment.ex index bafed6f..97efee3 100644 --- a/lib/nulla/models/media_attachment.ex +++ b/lib/nulla/models/media_attachment.ex @@ -1,6 +1,7 @@ defmodule Nulla.Models.MediaAttachment do use Ecto.Schema import Ecto.Changeset + alias Nulla.Models.Note @primary_key {:id, :integer, autogenerate: false} schema "media_attachments" do @@ -8,7 +9,7 @@ defmodule Nulla.Models.MediaAttachment do field :mime_type, :string field :description, :string - belongs_to :note, Nulla.Models.Note + belongs_to :note, Note timestamps(type: :utc_datetime) end diff --git a/lib/nulla/models/moderation_log.ex b/lib/nulla/models/moderation_log.ex index 7e30af1..1a62b71 100644 --- a/lib/nulla/models/moderation_log.ex +++ b/lib/nulla/models/moderation_log.ex @@ -1,6 +1,7 @@ defmodule Nulla.Models.ModerationLog do use Ecto.Schema import Ecto.Changeset + alias Nulla.Models.User @primary_key {:id, :integer, autogenerate: false} schema "moderation_logs" do @@ -10,7 +11,7 @@ defmodule Nulla.Models.ModerationLog do field :reason, :string field :metadata, :map - belongs_to :moderator, Nulla.Models.User + belongs_to :moderator, User timestamps() end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 4b80963..7d05339 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -1,30 +1,62 @@ defmodule Nulla.Models.Relation do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo + alias Nulla.Snowflake alias Nulla.Models.Actor - alias Nulla.Models.Activity @primary_key {:id, :integer, autogenerate: false} + @foreign_key_type :integer schema "relations" do - field :type, :string - field :status, :string + field :following, :boolean, default: false + field :followed_by, :boolean, default: false + field :showing_replies, :boolean, default: true + field :showing_reblogs, :boolean, default: true + field :notifying, :boolean, default: false + field :muting, :boolean, default: false + field :muting_notifications, :boolean, default: false + field :blocking, :boolean, default: false + field :blocked_by, :boolean, default: false + field :domain_blocking, :boolean, default: false + field :requested, :boolean, default: false + field :note, :string - belongs_to :source, Actor, foreign_key: :source_id, type: :integer - belongs_to :target, Actor, foreign_key: :target_id, type: :integer - belongs_to :activity, Activity, foreign_key: :activity_id, type: :integer + belongs_to :local_actor_id, Actor + belongs_to :remote_actor_id, Actor timestamps() end + @doc false def changeset(relation, attrs) do relation - |> cast(attrs, [:id, :source_id, :target_id, :type, :status, :activity_id]) - |> validate_required([:id, :source_id, :target_id, :type]) - |> validate_inclusion(:type, ~w(follow block mute friend_request)) - |> validate_inclusion(:status, ~w(pending accepted rejected active)) - |> foreign_key_constraint(:source_id) - |> foreign_key_constraint(:target_id) - |> foreign_key_constraint(:activity_id) - |> unique_constraint([:source_id, :target_id, :type]) + |> cast(attrs, [ + :id, + :following, + :followed_by, + :showing_replies, + :showing_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :source_id, + :target_id + ]) + |> validate_required([:id, :local_actor_id, :remote_actor_id]) + |> unique_constraint([:local_actor_id, :remote_actor_id]) end + + def create_relation(attrs) do + id = Snowflake.next_id() + + %__MODULE__{} + |> __MODULE__.changeset(attrs) + |> Changeset.put_change(:id, id) + |> Repo.insert() + end end diff --git a/lib/nulla/models/session.ex b/lib/nulla/models/session.ex index 0eb45b7..9058f6a 100644 --- a/lib/nulla/models/session.ex +++ b/lib/nulla/models/session.ex @@ -1,6 +1,7 @@ defmodule Nulla.Models.Session do use Ecto.Schema import Ecto.Changeset + alias Nulla.Models.User @primary_key {:id, :integer, autogenerate: false} schema "sessions" do @@ -8,7 +9,7 @@ defmodule Nulla.Models.Session do field :user_agent, :string field :ip, :string - belongs_to :user, Nulla.Models.User + belongs_to :user, User timestamps(type: :utc_datetime) end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 2d7742e..9d5966d 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -69,7 +69,7 @@ defmodule Nulla.Models.User do def update_last_active(user) do user - |> Ecto.Changeset.change(last_active_at: DateTime.utc_now()) + |> Changeset.change(last_active_at: DateTime.utc_now()) |> Repo.update() end end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index e3c55ea..e497888 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -1,7 +1,6 @@ defmodule NullaWeb.ActorController do use NullaWeb, :controller alias Nulla.ActivityPub - alias Nulla.Utils alias Nulla.Models.Actor alias Nulla.Models.Note alias Nulla.Models.InstanceSettings diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index fd0d7a8..0f63a9b 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -5,6 +5,7 @@ defmodule NullaWeb.InboxController do alias Nulla.Utils alias Nulla.Models.Actor alias Nulla.Models.Relation + alias Nulla.Models.Activity def inbox( conn, @@ -24,19 +25,19 @@ defmodule NullaWeb.InboxController do Activity.create_activity(%{ ap_id: follow_id, type: "Follow", - actor: actor_uri, + actor: remote_actor.id, object: target_uri }), accept_activity <- Activity.create_activity(%{ type: "Accept", - actor: target_uri, + actor: target_actor.id, object: follow_activity }), relation <- Relation.create_relation(%{ - id: 1, - follower: remote_actor.id, - followed: target_actor.id + followed_by: true, + local_actor_id: target_actor.id, + remote_actor_id: remote_actor.id }) do conn |> put_resp_content_type("application/activity+json") diff --git a/priv/repo/migrations/20250615131836_create_follows.exs b/priv/repo/migrations/20250615131836_create_follows.exs deleted file mode 100644 index c12b6ba..0000000 --- a/priv/repo/migrations/20250615131836_create_follows.exs +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Nulla.Repo.Migrations.CreateFollows do - use Ecto.Migration - - def change do - create table(:follows, primary_key: false) do - add :id, :bigint, primary_key: true - add :follower_id, references(:actors, on_delete: :delete_all), null: false - add :following_id, references(:actors, on_delete: :delete_all), null: false - - timestamps() - end - - create unique_index(:follows, [:follower_id, :following_id]) - create index(:follows, [:following_id]) - end -end diff --git a/priv/repo/migrations/20250617091354_create_relations.exs b/priv/repo/migrations/20250617091354_create_relations.exs index b4c66bf..55b7012 100644 --- a/priv/repo/migrations/20250617091354_create_relations.exs +++ b/priv/repo/migrations/20250617091354_create_relations.exs @@ -4,20 +4,28 @@ defmodule Nulla.Repo.Migrations.CreateActorRelations do def change do create table(:relations, primary_key: false) do add :id, :bigint, primary_key: true - add :source_id, :bigint, null: false - add :target_id, :bigint, null: false - add :type, :string, null: false - add :status, :string, null: false - add :activity_id, :bigint + add :following, :boolean, null: false, default: false + add :followed_by, :boolean, null: false, default: false + add :showing_replies, :boolean, null: false, default: true + add :showing_reblogs, :boolean, null: false, default: true + add :notifying, :boolean, null: false, default: false + add :muting, :boolean, null: false, default: false + add :muting_notifications, :boolean, null: false, default: false + add :blocking, :boolean, null: false, default: false + add :blocked_by, :boolean, null: false, default: false + add :domain_blocking, :boolean, null: false, default: false + add :requested, :boolean, null: false, default: false + add :note, :string + + add :local_actor_id, references(:actors, type: :bigint), null: false + add :remote_actor_id, references(:actors, type: :bigint), null: false timestamps() end - create index(:relations, [:source_id]) - create index(:relations, [:target_id]) - create index(:relations, [:type]) - create index(:relations, [:activity_id]) + create index(:relations, [:local_actor_id]) + create index(:relations, [:remote_actor_id]) - create unique_index(:relations, [:source_id, :target_id, :type]) + create unique_index(:relations, [:local_actor_id, :remote_actor_id]) end end From df548a4943def16cf10fadebcbb45a007563fa99 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 18 Jun 2025 09:13:59 +0200 Subject: [PATCH 069/144] Update --- lib/nulla/activitypub.ex | 81 +++++++--------- lib/nulla/models/activity.ex | 2 +- lib/nulla/models/actor.ex | 2 +- lib/nulla/models/instance_settings.ex | 6 +- lib/nulla/models/relation.ex | 61 ++++++++++-- lib/nulla/models/user.ex | 2 +- lib/nulla/utils.ex | 94 +------------------ lib/nulla_web/components/templates.ex | 18 ++-- .../components/templates/actor/show.html.heex | 5 +- .../controllers/follow_controller.ex | 26 ++--- lib/nulla_web/controllers/inbox_controller.ex | 15 +-- ...0250527054942_create_instance_settings.exs | 4 +- 12 files changed, 137 insertions(+), 179 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 16183a4..838aae4 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -1,4 +1,9 @@ defmodule Nulla.ActivityPub do + alias Nulla.Models.Actor + alias Nulla.Models.Activity + alias Nulla.Models.Note + alias Nulla.Models.InstanceSettings + @spec context() :: list() defp context do [ @@ -25,7 +30,7 @@ defmodule Nulla.ActivityPub do ] end - @spec actor(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + @spec actor(Actor.t()) :: Jason.OrderedObject.t() def actor(actor) do Jason.OrderedObject.new( "@context": context(), @@ -57,7 +62,7 @@ defmodule Nulla.ActivityPub do ) end - @spec note(String.t(), Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + @spec note(String.t(), Note.t()) :: Jason.OrderedObject.t() def note(domain, note) do attachment = case note.media_attachments do @@ -102,7 +107,7 @@ defmodule Nulla.ActivityPub do ) end - @spec activity(String.t(), Nulla.Models.Activity.t()) :: Jason.OrderedObject.t() + @spec activity(String.t(), Activity.t()) :: Jason.OrderedObject.t() def activity(domain, activity) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", @@ -113,35 +118,28 @@ defmodule Nulla.ActivityPub do ) end - @spec following(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() - def following(domain, actor, total) do + @spec following(Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def following(actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/users/#{actor.preferredUsername}/following", + id: "https://#{actor.domain}/users/#{actor.preferredUsername}/following", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/users/#{actor.preferredUsername}/following?page=1" + first: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=1" ) end - @spec following( - String.t(), - Nulla.Models.Actor.t(), - Integer.t(), - List.t(), - Integer.t(), - Integer.t() - ) :: Jason.OrderedObject.t() - def following(domain, actor, total, following_list, page, offset) - when is_integer(page) and page > 0 do + @spec following(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: + Jason.OrderedObject.t() + def following(actor, total, following_list, page, limit) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/@#{actor.preferredUsername}/following?page=#{page}", + id: "https://#{actor.domain}/@#{actor.preferredUsername}/following?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}", - prev: "https://#{domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}", - partOf: "https://#{domain}/users/#{actor.preferredUsername}/following", + next: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=#{page + 1}", + prev: "https://#{actor.domain}/users/#{actor.preferredUsername}/following?page=#{page - 1}", + partOf: "https://#{actor.domain}/users/#{actor.preferredUsername}/following", orderedItems: following_list ] @@ -153,7 +151,7 @@ defmodule Nulla.ActivityPub do end data = - if page * offset > total do + if page * limit > total do data |> Keyword.delete(:next) |> Keyword.delete(:prev) @@ -164,35 +162,29 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec followers(String.t(), Nulla.Models.Actor.t(), Integer.t()) :: Jason.OrderedObject.t() - def followers(domain, actor, total) do + @spec followers(Actor.t(), Integer.t()) :: Jason.OrderedObject.t() + def followers(actor, total) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/users/#{actor.preferredUsername}/followers", + id: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers", type: "OrderedCollection", totalItems: total, - first: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=1" + first: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=1" ) end - @spec followers( - String.t(), - Nulla.Models.Actor.t(), - Integer.t(), - List.t(), - Integer.t(), - Integer.t() - ) :: Jason.OrderedObject.t() - def followers(domain, actor, total, followers_list, page, offset) + @spec followers(Actor.t(), Integer.t(), List.t(), Integer.t(), Integer.t()) :: + Jason.OrderedObject.t() + def followers(actor, total, followers_list, page, limit) when is_integer(page) and page > 0 do data = [ "@context": "https://www.w3.org/ns/activitystreams", - id: "https://#{domain}/users#{actor.preferredUsername}/followers?page=#{page}", + id: "https://#{actor.domain}/users#{actor.preferredUsername}/followers?page=#{page}", type: "OrderedCollectionPage", totalItems: total, - next: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}", - prev: "https://#{domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}", - partOf: "https://#{domain}/users/#{actor.preferredUsername}/followers", + next: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=#{page + 1}", + prev: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers?page=#{page - 1}", + partOf: "https://#{actor.domain}/users/#{actor.preferredUsername}/followers", orderedItems: followers_list ] @@ -204,7 +196,7 @@ defmodule Nulla.ActivityPub do end data = - if page * offset > total do + if page * limit > total do data |> Keyword.delete(:next) |> Keyword.delete(:prev) @@ -215,7 +207,7 @@ defmodule Nulla.ActivityPub do Jason.OrderedObject.new(data) end - @spec webfinger(Nulla.Models.Actor.t()) :: Jason.OrderedObject.t() + @spec webfinger(Actor.t()) :: Jason.OrderedObject.t() def webfinger(actor) do Jason.OrderedObject.new( subject: "#{actor.preferredUsername}@#{actor.domain}", @@ -255,8 +247,7 @@ defmodule Nulla.ActivityPub do ) end - @spec nodeinfo(String.t(), Map.t(), Nulla.Models.InstanceSettings.t()) :: - Jason.OrderedObject.t() + @spec nodeinfo(String.t(), Map.t(), InstanceSettings.t()) :: Jason.OrderedObject.t() def nodeinfo(version, users, instance) do Jason.OrderedObject.new( version: "2.0", @@ -323,7 +314,7 @@ defmodule Nulla.ActivityPub do ) end - @spec activity_note(Nulla.Models.Note.t()) :: Jason.OrderedObject.t() + @spec activity_note(Note.t()) :: Jason.OrderedObject.t() def activity_note(note) do Jason.OrderedObject.new( id: @@ -349,7 +340,7 @@ defmodule Nulla.ActivityPub do ) end - @spec follow_accept(Nulla.Models.Activity.t()) :: Jason.OrderedObject.t() + @spec follow_accept(Activity.t()) :: Jason.OrderedObject.t() def follow_accept(activity) do Jason.OrderedObject.new( "@context": "https://www.w3.org/ns/activitystreams", diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index f2649bf..f752917 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -28,7 +28,7 @@ defmodule Nulla.Models.Activity do %__MODULE__{} |> __MODULE__.changeset(attrs) - |> Changeset.put_change(:id, id) + |> put_change(:id, id) |> Repo.insert() end end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index d181c38..8db516e 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -106,7 +106,7 @@ defmodule Nulla.Models.Actor do %__MODULE__{} |> changeset(attrs) - |> Changeset.put_change(:id, id) + |> put_change(:id, id) |> Repo.insert() end diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index 0a2f38f..37ffe88 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -11,7 +11,7 @@ defmodule Nulla.Models.InstanceSettings do field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 - field :api_offset, :integer, default: 100 + field :api_limit, :integer, default: 100 field :public_key, :string field :private_key, :string end @@ -26,7 +26,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, - :api_offset, + :api_limit, :public_key, :private_key ]) @@ -37,7 +37,7 @@ defmodule Nulla.Models.InstanceSettings do :registration, :max_characters, :max_upload_size, - :api_offset, + :api_limit, :public_key, :private_key ]) diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 7d05339..3799af9 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -1,6 +1,7 @@ defmodule Nulla.Models.Relation do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Nulla.Repo alias Nulla.Snowflake alias Nulla.Models.Actor @@ -21,8 +22,8 @@ defmodule Nulla.Models.Relation do field :requested, :boolean, default: false field :note, :string - belongs_to :local_actor_id, Actor - belongs_to :remote_actor_id, Actor + belongs_to :local_actor, Actor + belongs_to :remote_actor, Actor timestamps() end @@ -44,19 +45,65 @@ defmodule Nulla.Models.Relation do :domain_blocking, :requested, :note, - :source_id, - :target_id + :local_actor_id, + :remote_actor_id ]) |> validate_required([:id, :local_actor_id, :remote_actor_id]) |> unique_constraint([:local_actor_id, :remote_actor_id]) end - def create_relation(attrs) do + def create_relation(attrs) do id = Snowflake.next_id() %__MODULE__{} |> __MODULE__.changeset(attrs) - |> Changeset.put_change(:id, id) + |> put_change(:id, id) |> Repo.insert() - end + end + + def count_following(local_actor_id) do + __MODULE__ + |> where([r], r.local_actor_id == ^local_actor_id and r.following == true) + |> select([r], count(r.id)) + |> Repo.one() + end + + def get_following(local_actor_id, page, limit) when is_integer(page) and page > 0 do + offset = (page - 1) * limit + + query = + from r in __MODULE__, + join: a in Actor, + on: a.id == r.remote_actor_id, + where: r.local_actor_id == ^local_actor_id and r.following == true, + order_by: [asc: a.published], + offset: ^offset, + limit: ^limit, + select: a + + Repo.all(query) + end + + def count_followers(local_actor_id) do + __MODULE__ + |> where([r], r.local_actor_id == ^local_actor_id and r.followed_by == true) + |> select([r], count(r.id)) + |> Repo.one() + end + + def get_followers(local_actor_id, page, limit) when is_integer(page) and page > 0 do + offset = (page - 1) * limit + + query = + from r in __MODULE__, + join: a in Actor, + on: a.id == r.remote_actor_id, + where: r.local_actor_id == ^local_actor_id and r.followed_by == true, + order_by: [asc: a.published], + offset: ^offset, + limit: ^limit, + select: a + + Repo.all(query) + end end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 9d5966d..7766afe 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -69,7 +69,7 @@ defmodule Nulla.Models.User do def update_last_active(user) do user - |> Changeset.change(last_active_at: DateTime.utc_now()) + |> change(last_active_at: DateTime.utc_now()) |> Repo.update() end end diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index fc788c0..4e74f08 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -1,102 +1,16 @@ defmodule Nulla.Utils do - import Ecto.Query - alias Nulla.Repo - alias Nulla.Models.User - alias Nulla.Models.Follow + alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings - def count_following_by_username!(username) do - case Repo.get_by(User, username: username) do - nil -> - {:error, :user_not_found} - - %User{id: user_id} -> - count = - Follow - |> where([f], f.user_id == ^user_id) - |> select([f], count(f.id)) - |> Repo.one() - - count - end - end - - def get_following_users_by_username!(username, page) when is_integer(page) and page > 0 do - case Repo.get_by(User, username: username) do - nil -> - {:error, :user_not_found} - - %User{id: user_id} -> - instance_settings = InstanceSettings.get_instance_settings!() - per_page = instance_settings.api_offset - offset = (page - 1) * per_page - - query = - from( - [f, u] in from(f in Follow, - join: u in User, - on: u.id == f.target_id, - where: f.user_id == ^user_id, - order_by: [asc: u.inserted_at], - offset: ^offset, - limit: ^per_page, - select: u - ) - ) - - users = Repo.all(query) - - users - end - end - - def count_followers_by_username!(username) do - case Repo.get_by(User, username: username) do - nil -> - 0 - - %User{id: user_id} -> - from(f in Follow, where: f.target_id == ^user_id) - |> select([f], count(f.id)) - |> Repo.one() - end - end - - def get_followers_by_username!(username, page) when is_integer(page) and page > 0 do - case Repo.get_by(User, username: username) do - nil -> - {:error, :user_not_found} - - %User{id: user_id} -> - instance_settings = InstanceSettings.get_instance_settings!() - per_page = instance_settings.api_offset - offset = (page - 1) * per_page - - query = - from f in Follow, - where: f.target_id == ^user_id, - join: u in User, - on: u.id == f.user_id, - order_by: [asc: u.inserted_at], - offset: ^offset, - limit: ^per_page, - select: u - - users = Repo.all(query) - - users - end - end - def resolve_local_actor("https://" <> _ = uri) do case URI.parse(uri).path do "/@" <> username -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - case User.get_user_by_username_and_domain(username, domain) do + case Actor.get_actor(username, domain) do nil -> {:error, :not_found} - user -> {:ok, user} + user -> user end _ -> @@ -110,7 +24,7 @@ defmodule Nulla.Utils do {"Accept", "application/activity+json"} ]) - case Finch.request(request, MyApp.Finch) do + case Finch.request(request, Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> case Jason.decode(body) do {:ok, data} -> {:ok, data} diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index 817fa17..6a61d9b 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -18,33 +18,37 @@ defmodule NullaWeb.ActorHTML do def format_registration_date(date) do now = Date.utc_today() formatted = Date.to_string(date) |> String.replace("-", "/") - + diff_days = Date.diff(now, date) - + cond do diff_days == 0 -> "#{formatted} (today)" - + diff_days == 1 -> "#{formatted} (1 day ago)" - + diff_days < 30 -> "#{formatted} (#{diff_days} days ago)" - + diff_days < 365 -> year_diff = now.year - date.year month_diff = now.month - date.month day_correction = if now.day < date.day, do: -1, else: 0 months = year_diff * 12 + month_diff + day_correction + if months == 1 do "#{formatted} (1 month ago)" else "#{formatted} (#{months} months ago)" end - + true -> year_diff = now.year - date.year - years = if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff + + years = + if {now.month, now.day} < {date.month, date.day}, do: year_diff - 1, else: year_diff + if years == 1 do "#{formatted} (1 year ago)" else diff --git a/lib/nulla_web/components/templates/actor/show.html.heex b/lib/nulla_web/components/templates/actor/show.html.heex index fb5fedf..76d3f63 100644 --- a/lib/nulla_web/components/templates/actor/show.html.heex +++ b/lib/nulla_web/components/templates/actor/show.html.heex @@ -19,7 +19,8 @@
+
+
+
+
+
+
+
+
+ diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index c0237f5..1ee7130 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -6,12 +6,12 @@ defmodule NullaWeb.NoteController do def show(conn, %{"username" => username, "id" => id}) do accept = List.first(get_req_header(conn, "accept")) - note = Note.get_note!(id) |> Repo.preload([:user, :media_attachments]) + note = Note.get_note(id) |> Repo.preload([:actor, :media_attachments]) - if username != note.user.username do + if username != note.actor.preferredUsername do conn |> put_status(:not_found) - |> json(%{error: "Note not found"}) + |> json(%{error: "Not Found"}) |> halt() end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index cd80eb6..d912802 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -35,7 +35,7 @@ defmodule NullaWeb.Router do get "/followers", FollowController, :followers post "/inbox", InboxController, :inbox get "/outbox", OutboxController, :outbox - get "/statuses/:id", NoteController, :show + get "/notes/:id", NoteController, :show end scope "/@:username" do diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index a930b14..d872437 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -4,11 +4,12 @@ defmodule Nulla.Repo.Migrations.CreateNotes do def change do create table(:notes, primary_key: false) do add :id, :bigint, primary_key: true - add :content, :text + add :inReplyTo, :string + add :url, :string add :visibility, :string, default: "public" add :sensitive, :boolean, default: false + add :content, :text add :language, :string - add :in_reply_to, :string add :actor_id, references(:actors, on_delete: :delete_all) timestamps(type: :utc_datetime) diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs new file mode 100644 index 0000000..d3f87ee --- /dev/null +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -0,0 +1,75 @@ +defmodule NullaWeb.NoteControllerTest do + use NullaWeb.ConnCase + alias Nulla.KeyGen + alias Nulla.Snowflake + alias Nulla.Models.Actor + alias Nulla.Models.Note + + describe "GET /notes/id" do + test "returns ActivityPub JSON with note", %{conn: conn} do + {publicKeyPem, _privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test", + type: "Person", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", + preferredUsername: "test", + name: "Test", + summary: "Test User", + url: "http://localhost/@test", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + note_id = Snowflake.next_id() + + {:ok, note} = + Note.create_note(%{ + id: note_id, + url: "#{actor.url}/#{note_id}", + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) + + conn = + conn + |> put_req_header("accept", "application/activity+json") + |> get(~p"/users/#{actor.preferredUsername}/notes/#{note.id}") + + assert response = json_response(conn, 200) + + assert is_list(response["@context"]) + assert response["id"] == "http://localhost/users/test/notes/#{note.id}" + assert response["type"] == "Note" + assert response["summary"] == nil + assert response["inReplyTo"] == nil + assert {:ok, _dt, _offset} = DateTime.from_iso8601(response["published"]) + assert response["url"] == note.url + assert response["attributedTo"] == "http://localhost/users/test" + assert is_list(response["to"]) + assert is_list(response["cc"]) + assert response["sensitive"] == false + assert is_binary(response["content"]) + assert is_map(response["contentMap"]) + assert is_list(response["attachment"]) + end + end +end From 43ee272ca1708dd6e77beedca83730d68bf2775a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 19 Jun 2025 17:21:52 +0200 Subject: [PATCH 089/144] Update --- .../components/templates/note/show.html.heex | 1 + lib/nulla_web/controllers/note_controller.ex | 46 +++++++++++----- .../controllers/note_controller_test.exs | 54 +++++++++++++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/lib/nulla_web/components/templates/note/show.html.heex b/lib/nulla_web/components/templates/note/show.html.heex index 52faab3..453f308 100644 --- a/lib/nulla_web/components/templates/note/show.html.heex +++ b/lib/nulla_web/components/templates/note/show.html.heex @@ -15,6 +15,7 @@
+ {@note.content}
diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index 1ee7130..bafa1c0 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -5,22 +5,40 @@ defmodule NullaWeb.NoteController do alias Nulla.Models.Note def show(conn, %{"username" => username, "id" => id}) do - accept = List.first(get_req_header(conn, "accept")) - note = Note.get_note(id) |> Repo.preload([:actor, :media_attachments]) + case Integer.parse(id) do + {int_id, ""} -> + note = Note.get_note(int_id) |> Repo.preload([:actor, :media_attachments]) - if username != note.actor.preferredUsername do - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - |> halt() - end + cond do + is_nil(note) -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() - if accept in ["application/activity+json", "application/ld+json"] do - conn - |> put_resp_content_type("application/activity+json") - |> json(ActivityPub.note(note)) - else - render(conn, :show, note: note, layout: false) + username != note.actor.preferredUsername -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() + + true -> + accept = List.first(get_req_header(conn, "accept")) + + if accept in ["application/activity+json", "application/ld+json"] do + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.note(note)) + else + render(conn, :show, note: note, layout: false) + end + end + + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() end end end diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs index d3f87ee..a4bcf49 100644 --- a/test/nulla_web/controllers/note_controller_test.exs +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -71,5 +71,59 @@ defmodule NullaWeb.NoteControllerTest do assert is_map(response["contentMap"]) assert is_list(response["attachment"]) end + + test "renders HTML if Accept header is regular", %{conn: conn} do + {publicKeyPem, _privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test", + type: "Person", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", + preferredUsername: "test", + name: "Test", + summary: "Test User", + url: "http://localhost/@test", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + note_id = Snowflake.next_id() + + {:ok, note} = + Note.create_note(%{ + id: note_id, + url: "#{actor.url}/#{note_id}", + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) + + conn = get(conn, ~p"/users/test/notes/#{note.id}") + + assert html_response(conn, 200) =~ note.content + end + + test "returns 404 if note not found", %{conn: conn} do + conn = get(conn, ~p"/users/test/notes/nonexistent") + + assert json_response(conn, 404)["error"] == "Not Found" + end end end From b475f86313753f266b7221c26a22b8a3395ed1f8 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 19 Jun 2025 17:50:25 +0200 Subject: [PATCH 090/144] Remove user_controller.ex --- lib/nulla_web/controllers/user_controller.ex | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 lib/nulla_web/controllers/user_controller.ex diff --git a/lib/nulla_web/controllers/user_controller.ex b/lib/nulla_web/controllers/user_controller.ex deleted file mode 100644 index 10d530d..0000000 --- a/lib/nulla_web/controllers/user_controller.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule NullaWeb.UserController do - use NullaWeb, :controller -end From 1ce7fdade0192b151cdbfaeba7deba79370bdddf Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 09:20:26 +0200 Subject: [PATCH 091/144] Update actor --- lib/nulla/models/actor.ex | 2 ++ priv/repo/migrations/20250615130714_create_actors.exs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index e173673..6eaf6d7 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -90,6 +90,8 @@ defmodule Nulla.Models.Actor do :publicKey, :endpoints ]) + |> unique_constraint([:preferredUsername, :domain]) + |> unique_constraint(:ap_id) end def create_actor(attrs) when is_map(attrs) do diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs index 8098ce9..116a120 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -31,5 +31,8 @@ defmodule Nulla.Repo.Migrations.CreateActors do add :vcard_bday, :date add :vcard_Address, :string end + + create unique_index(:actors, [:preferredUsername, :domain]) + create unique_index(:actors, [:ap_id]) end end From 56490e3934b729ddfb97ff535bd547516d0b2828 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 09:20:44 +0200 Subject: [PATCH 092/144] Update note --- lib/nulla/models/note.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 18a26cf..6d3cf8b 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -48,7 +48,8 @@ defmodule Nulla.Models.Note do from(n in __MODULE__, where: n.actor_id == ^actor_id, order_by: [desc: n.inserted_at], - limit: ^limit + limit: ^limit, + preload: [:actor, :media_attachments] ) |> Repo.all() end @@ -57,7 +58,8 @@ defmodule Nulla.Models.Note do from(n in __MODULE__, where: n.actor_id == ^actor_id and n.id < ^max_id, order_by: [desc: n.inserted_at], - limit: ^limit + limit: ^limit, + preload: [:actor, :media_attachments] ) |> Repo.all() end From c288831bb26108f27d3766e1faae7c9bcec96d80 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 09:20:57 +0200 Subject: [PATCH 093/144] Add outbox_controller_test.exs --- .../controllers/outbox_controller_test.exs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/nulla_web/controllers/outbox_controller_test.exs diff --git a/test/nulla_web/controllers/outbox_controller_test.exs b/test/nulla_web/controllers/outbox_controller_test.exs new file mode 100644 index 0000000..cd5512c --- /dev/null +++ b/test/nulla_web/controllers/outbox_controller_test.exs @@ -0,0 +1,85 @@ +defmodule NullaWeb.OutboxControllerTest do + use NullaWeb.ConnCase + alias Nulla.KeyGen + alias Nulla.Snowflake + alias Nulla.Models.Actor + alias Nulla.Models.Note + + setup do + {publicKeyPem, _privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test", + type: "Person", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", + preferredUsername: "test", + name: "Test", + summary: "Test User", + url: "http://localhost/@test", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + note_id = Snowflake.next_id() + + {:ok, _note} = + Note.create_note(%{ + url: "#{actor.url}/#{note_id}", + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) + + :ok + end + + describe "GET /users/username/outbox" do + test "returns ActivityPub JSON of outbox", %{conn: conn} do + conn = + conn + |> get(~p"/users/test/outbox") + + assert response = json_response(conn, 200) + + assert is_binary(response["@context"]) + assert response["id"] == "http://localhost/users/test/outbox" + assert response["type"] == "OrderedCollection" + assert response["totalItems"] == 1 + assert response["first"] == "http://localhost/users/test/outbox?page=true" + assert response["last"] == "http://localhost/users/test/outbox?min_id=0&page=true" + end + + test "returns ActivityPub JSON of outbox with params", %{conn: conn} do + conn = + conn + |> get(~p"/users/test/outbox?page=true") + + assert response = json_response(conn, 200) + + assert is_list(response["@context"]) + assert response["id"] == "http://localhost/users/test/outbox?page=true" + assert response["type"] == "OrderedCollectionPage" + assert is_binary(response["next"]) + assert is_binary(response["prev"]) + assert response["partOf"] == "http://localhost/users/test/outbox" + assert Enum.any?(response["orderedItems"]) + end + end +end From 4929ce21fdc10274c6c4b8f3b641719d1c32e1a7 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 09:25:53 +0200 Subject: [PATCH 094/144] Format --- .../controllers/outbox_controller_test.exs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/nulla_web/controllers/outbox_controller_test.exs b/test/nulla_web/controllers/outbox_controller_test.exs index cd5512c..18ad04c 100644 --- a/test/nulla_web/controllers/outbox_controller_test.exs +++ b/test/nulla_web/controllers/outbox_controller_test.exs @@ -37,15 +37,15 @@ defmodule NullaWeb.OutboxControllerTest do endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") }) - note_id = Snowflake.next_id() + note_id = Snowflake.next_id() - {:ok, _note} = - Note.create_note(%{ - url: "#{actor.url}/#{note_id}", - content: "Hello World from Nulla!", - language: "en", - actor_id: actor.id - }) + {:ok, _note} = + Note.create_note(%{ + url: "#{actor.url}/#{note_id}", + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) :ok end From 867f94572d3790f348b36e2c123ced854704dfa2 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 16:46:58 +0200 Subject: [PATCH 095/144] Update media_attachment --- lib/nulla/models/media_attachment.ex | 26 ++++++++++++++----- ...0250615131644_create_media_attachments.exs | 9 ++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/models/media_attachment.ex index 97efee3..5369007 100644 --- a/lib/nulla/models/media_attachment.ex +++ b/lib/nulla/models/media_attachment.ex @@ -5,18 +5,30 @@ defmodule Nulla.Models.MediaAttachment do @primary_key {:id, :integer, autogenerate: false} schema "media_attachments" do - field :file, :string - field :mime_type, :string - field :description, :string + field :type, :string + field :mediaType, :string + field :url, :string + field :name, :string + field :width, :integer + field :height, :integer belongs_to :note, Note timestamps(type: :utc_datetime) end - def changeset(media, attrs) do - media - |> cast(attrs, [:note_id, :file, :mime_type, :description]) - |> validate_required([:note_id, :file]) + def changeset(media_attachment, attrs) do + media_attachment + |> cast(attrs, [:type, :mediaType, :url, :name, :width, :height, :note_id]) + |> validate_required([:type, :mediaType, :url, :note_id]) + end + + def create_media_attachment(attrs) when is_map(attrs) do + id = Map.get(attrs, :id, Snowflake.next_id()) + + %__MODULE__{} + |> changeset(attrs) + |> put_change(:id, id) + |> Repo.insert() end end diff --git a/priv/repo/migrations/20250615131644_create_media_attachments.exs b/priv/repo/migrations/20250615131644_create_media_attachments.exs index 1d9fe64..efacd89 100644 --- a/priv/repo/migrations/20250615131644_create_media_attachments.exs +++ b/priv/repo/migrations/20250615131644_create_media_attachments.exs @@ -4,10 +4,13 @@ defmodule Nulla.Repo.Migrations.CreateMediaAttachments do def change do create table(:media_attachments, primary_key: false) do add :id, :bigint, primary_key: true + add :type, :string, null: false + add :mediaType, :string, null: false + add :url, :string, null: false + add :name, :string + add :width, :integer + add :height, :integer add :note_id, references(:notes, on_delete: :delete_all), null: false - add :file, :string, null: false - add :mime_type, :string - add :description, :string timestamps(type: :utc_datetime) end From 5d4ac46b67711df57ef571534bac01cd20278a31 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 17:58:36 +0200 Subject: [PATCH 096/144] Update upload dir --- .gitignore | 4 ++-- lib/nulla_web.ex | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 43182ba..ad243a8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,8 @@ nulla-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ -# Ignore static files -/priv/static/files/ +# Ignore upload dir +/priv/static/system/ # Ignore digested assets cache. /priv/static/cache_manifest.json diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex index 9feaa88..0c0b0cb 100644 --- a/lib/nulla_web.ex +++ b/lib/nulla_web.ex @@ -17,7 +17,7 @@ defmodule NullaWeb do those modules here. """ - def static_paths, do: ~w(assets files fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets system fonts images favicon.ico robots.txt) def router do quote do From ea667243e0c44aca8f64edf345e51de0482a7dc4 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 19:41:59 +0200 Subject: [PATCH 097/144] Update media_attachment --- lib/nulla/models/media_attachment.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/nulla/models/media_attachment.ex b/lib/nulla/models/media_attachment.ex index 5369007..61dbb1f 100644 --- a/lib/nulla/models/media_attachment.ex +++ b/lib/nulla/models/media_attachment.ex @@ -1,6 +1,8 @@ defmodule Nulla.Models.MediaAttachment do use Ecto.Schema import Ecto.Changeset + alias Nulla.Repo + alias Nulla.Snowflake alias Nulla.Models.Note @primary_key {:id, :integer, autogenerate: false} From 4a79081fc8b64f535604ff0aa39236a53a7db85e Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 20 Jun 2025 19:42:39 +0200 Subject: [PATCH 098/144] Update uploader.ex --- lib/nulla/uploader.ex | 69 +++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex index 2abd925..bb18bf2 100644 --- a/lib/nulla/uploader.ex +++ b/lib/nulla/uploader.ex @@ -1,25 +1,64 @@ defmodule Nulla.Uploader do - def upload(%Plug.Upload{path: temp_path, filename: original_name}) do + alias Nulla.Snowflake + alias Nulla.Models.MediaAttachment + + @upload_base "priv/static" + @upload_prefix "system" + + def upload( + %Plug.Upload{path: temp_path, filename: original_name}, + dir, + description, + domain + ) do {:ok, binary} = File.read(temp_path) - hash = :crypto.hash(:sha1, binary) |> Base.encode16(case: :lower) - file_type = Path.extname(original_name) + ext = Path.extname(original_name) + mimetype = MIME.type(ext) - segments = - hash - |> String.slice(0, 15) - |> String.codepoints() - |> Enum.chunk_every(3) - |> Enum.map(&Enum.join/1) + type = + cond do + mimetype =~ "image" -> "Image" + mimetype =~ "video" -> "Video" + mimetype =~ "audio" -> "Audio" + true -> "Document" + end - filename = String.slice(hash, 15..-1//1) <> file_type - relative_path = Path.join(segments) <> "/" <> filename + filename = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) <> ext - dest_path = Path.join(["priv/static/files", relative_path]) + media_attachment_id = Snowflake.next_id() - dest_path |> Path.dirname() |> File.mkdir_p!() + relative_path = + Path.join([ + @upload_prefix, + dir, + partition_id(media_attachment_id), + "original", + filename + ]) - File.write!(dest_path, binary) + full_path = Path.join(@upload_base, relative_path) - relative_path + full_path |> Path.dirname() |> File.mkdir_p!() + + File.write!(full_path, binary) + + MediaAttachment.create_media_attachment(%{ + id: media_attachment_id, + type: type, + mediaType: mimetype, + url: "https://#{domain}/#{relative_path}", + name: description, + width: 1, + height: 1 + }) + end + + defp partition_id(id) do + id + |> Integer.to_string() + |> String.pad_leading(18, "0") + |> String.graphemes() + |> Enum.chunk_every(3) + |> Enum.map_join("/", &Enum.join/1) end end From f88c247a5c372989f410fe7fe624bdfb3b561db2 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 10:23:49 +0200 Subject: [PATCH 099/144] Add auth templates --- .../templates/auth/sign_in.html.heex | 9 ++++++ .../templates/auth/sign_up.html.heex | 12 +++++++ lib/nulla_web/controllers/auth_controller.ex | 31 +++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 lib/nulla_web/components/templates/auth/sign_in.html.heex create mode 100644 lib/nulla_web/components/templates/auth/sign_up.html.heex diff --git a/lib/nulla_web/components/templates/auth/sign_in.html.heex b/lib/nulla_web/components/templates/auth/sign_in.html.heex new file mode 100644 index 0000000..1cdbb55 --- /dev/null +++ b/lib/nulla_web/components/templates/auth/sign_in.html.heex @@ -0,0 +1,9 @@ +
+
+ + + + + +
+
diff --git a/lib/nulla_web/components/templates/auth/sign_up.html.heex b/lib/nulla_web/components/templates/auth/sign_up.html.heex new file mode 100644 index 0000000..4685573 --- /dev/null +++ b/lib/nulla_web/components/templates/auth/sign_up.html.heex @@ -0,0 +1,12 @@ +
+
+ + + + + + + + +
+
diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index 3edddb1..a4df65d 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -1,12 +1,37 @@ defmodule NullaWeb.AuthController do use NullaWeb, :controller + alias Nulla.Models.User - def sign_in do + def sign_in_view(conn, _params) do + render(conn, :sign_in, layout: false) end - def sign_out do + def sign_in(conn, _params) do + conn + |> redirect(to: "/") end - def sign_up do + def sign_out(conn, _params) do + conn + |> configure_session(drop: true) + |> put_flash(:info, "You have been logged out.") + |> redirect(to: "/") + end + + def sign_up_view(conn, _params) do + render(conn, :sign_up, layout: false) + end + + def sign_up(conn, params) do + case User.create_user(params) do + {:ok, user} -> + conn + |> put_session(:user_id, user.id) + |> put_flash(:info, "You're registred!") + |> redirect(to: "/") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "sign_up.html", changeset: changeset) + end end end From 577e273a1b63a887de81b2e1b2bd8b61f485cc4e Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 10:24:40 +0200 Subject: [PATCH 100/144] Add page --- lib/nulla_web/components/templates.ex | 12 ++++++++++++ .../components/templates/page/home.html.heex | 0 lib/nulla_web/controllers/page_controller.ex | 7 +++++++ lib/nulla_web/router.ex | 10 +++++++--- test/nulla_web/controllers/page_controller_test.exs | 9 +++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 lib/nulla_web/components/templates/page/home.html.heex create mode 100644 lib/nulla_web/controllers/page_controller.ex create mode 100644 test/nulla_web/controllers/page_controller_test.exs diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index 3ba56db..be2365a 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -1,3 +1,15 @@ +defmodule NullaWeb.PageHTML do + use NullaWeb, :html + + embed_templates "templates/page/*" +end + +defmodule NullaWeb.AuthHTML do + use NullaWeb, :html + + embed_templates "templates/auth/*" +end + defmodule NullaWeb.ActorHTML do use NullaWeb, :html diff --git a/lib/nulla_web/components/templates/page/home.html.heex b/lib/nulla_web/components/templates/page/home.html.heex new file mode 100644 index 0000000..e69de29 diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex new file mode 100644 index 0000000..5d3e868 --- /dev/null +++ b/lib/nulla_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule NullaWeb.PageController do + use NullaWeb, :controller + + def home(conn, _params) do + render(conn, :home, layout: false) + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index d912802..811ff54 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -17,6 +17,8 @@ defmodule NullaWeb.Router do scope "/", NullaWeb do pipe_through :browser + get "/", PageController, :home + get "/.well-known/host-meta", HostmetaController, :index get "/.well-known/webfinger", WebfingerController, :index get "/.well-known/nodeinfo", NodeinfoController, :index @@ -24,9 +26,11 @@ defmodule NullaWeb.Router do post "/inbox", InboxController, :inbox scope "/auth" do - get "/sign_in", AuthController, :sign_in - post "/sign_out", AuthController, :sign_out - get "/sign_up", AuthController, :sign_up + get "/sign_in", AuthController, :sign_in_view + post "/sign_in", AuthController, :sign_in + delete "/sign_out", AuthController, :sign_out + get "/sign_up", AuthController, :sign_up_view + post "/sign_up", AuthController, :sign_up end scope "/users/:username" do diff --git a/test/nulla_web/controllers/page_controller_test.exs b/test/nulla_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..48fa534 --- /dev/null +++ b/test/nulla_web/controllers/page_controller_test.exs @@ -0,0 +1,9 @@ +defmodule NullaWeb.PageControllerTest do + use NullaWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + + assert String.length(html_response(conn, 200)) > 0 + end +end From 5989f3649318ae03ed883bae3bb78804a00fcc19 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 11:07:44 +0200 Subject: [PATCH 101/144] Move sign_in and sign_up views to page_controller.ex --- lib/nulla_web/controllers/auth_controller.ex | 10 ++-------- lib/nulla_web/controllers/page_controller.ex | 8 ++++++++ lib/nulla_web/router.ex | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index a4df65d..4b84aeb 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -1,10 +1,8 @@ defmodule NullaWeb.AuthController do use NullaWeb, :controller alias Nulla.Models.User - - def sign_in_view(conn, _params) do - render(conn, :sign_in, layout: false) - end + alias Nulla.Models.Actor + alias Nulla.Models.InstanceSettings def sign_in(conn, _params) do conn @@ -18,10 +16,6 @@ defmodule NullaWeb.AuthController do |> redirect(to: "/") end - def sign_up_view(conn, _params) do - render(conn, :sign_up, layout: false) - end - def sign_up(conn, params) do case User.create_user(params) do {:ok, user} -> diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex index 5d3e868..1212f3d 100644 --- a/lib/nulla_web/controllers/page_controller.ex +++ b/lib/nulla_web/controllers/page_controller.ex @@ -4,4 +4,12 @@ defmodule NullaWeb.PageController do def home(conn, _params) do render(conn, :home, layout: false) end + + def sign_in(conn, _params) do + render(conn, :sign_in, layout: false) + end + + def sign_up(conn, _params) do + render(conn, :sign_up, layout: false) + end end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 811ff54..500aafa 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -26,10 +26,10 @@ defmodule NullaWeb.Router do post "/inbox", InboxController, :inbox scope "/auth" do - get "/sign_in", AuthController, :sign_in_view + get "/sign_in", PageController, :sign_in post "/sign_in", AuthController, :sign_in delete "/sign_out", AuthController, :sign_out - get "/sign_up", AuthController, :sign_up_view + get "/sign_up", PageController, :sign_up post "/sign_up", AuthController, :sign_up end From 2ebf61fd34165eff67fb29fde738e3481f0ca434 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 11:25:42 +0200 Subject: [PATCH 102/144] Format --- .../components/templates/auth/sign_up.html.heex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/nulla_web/components/templates/auth/sign_up.html.heex b/lib/nulla_web/components/templates/auth/sign_up.html.heex index 4685573..db4190d 100644 --- a/lib/nulla_web/components/templates/auth/sign_up.html.heex +++ b/lib/nulla_web/components/templates/auth/sign_up.html.heex @@ -6,7 +6,14 @@ - + From 3ad43376c48e3c97231d341f04e38d226ee3a9b6 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 12:12:52 +0200 Subject: [PATCH 103/144] Install argon2_elixir --- mix.exs | 3 ++- mix.lock | 40 ++++++++++++++++------------------------ 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/mix.exs b/mix.exs index 09ceeec..c51a18a 100644 --- a/mix.exs +++ b/mix.exs @@ -57,7 +57,8 @@ defmodule Nulla.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:argon2_elixir, "~> 4.1"} ] end diff --git a/mix.lock b/mix.lock index c107605..92840a8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,52 +1,44 @@ %{ - "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, - "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, - "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, + "ecto": {:hex, :ecto, "3.13.1", "ebb11c2f0307ff62e8aaba57def59ad920a3cbd89d002b1118944cbf598c13c7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d9ea5075a6f3af9cd2cdbabe8a0759eb73b485e981fd7c03014f79479ac85340"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.1", "d3d76d78afd2757644b5c4f7ca37f90bcf1e05d05a06cca8526e30cefb0034a1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fba0174284fd339f69376b0405942036ce5f0ff7d59402a6ccf3b7ce2903198"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, - "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.10", "d3d54f751ca538b17313541cabb1ab090a0d26e08ba914b49b6648022fa476f4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13f833a39b1368117e0529c0fe5029930a9bf11e2fb805c2263fcc32950f07a2"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, + "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "swoosh": {:hex, :swoosh, "1.18.4", "5f5f325cfbc68d454f1606421f2dd02d1b20fd03e10905e9728b26662ae01f1d", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8b45e6f9109bdf89f3d83f810e0cc97c1c971925e72fc4f47da42959d8487ee"}, + "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, - "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, - "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, - "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, } From 1b404b3ea9221e5a0259ba916a8e87bd540dbac7 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 12:13:50 +0200 Subject: [PATCH 104/144] Update sign_up --- lib/nulla/models/actor.ex | 35 ++++++++++++++++++++ lib/nulla_web/controllers/auth_controller.ex | 27 +++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index 6eaf6d7..a4f579c 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -103,6 +103,41 @@ defmodule Nulla.Models.Actor do |> Repo.insert() end + def create_actor_minimal(username, domain, publicKeyPem) do + id = Snowflake.next_id() + + attrs = %{ + id: id, + domain: domain, + ap_id: "https://#{domain}/users/#{username}", + type: "Person", + following: "https://#{domain}/users/#{username}/following", + followers: "https://#{domain}/users/#{username}/followers", + inbox: "https://#{domain}/users/#{username}/inbox", + outbox: "https://#{domain}/users/#{username}/outbox", + featured: "https://#{domain}/users/#{username}/collections/featured", + featuredTags: "https://#{domain}/users/#{username}/collections/tags", + preferredUsername: username, + url: "https://#{domain}/@#{username}", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "https://#{domain}/users/#{username}#main-key", + owner: "https://#{domain}/users/#{username}", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "https://#{domain}/inbox") + } + + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + end + def get_actor(username, domain) do Repo.get_by(__MODULE__, preferredUsername: username, domain: domain) end diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index 4b84aeb..5755775 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -16,14 +16,27 @@ defmodule NullaWeb.AuthController do |> redirect(to: "/") end - def sign_up(conn, params) do - case User.create_user(params) do - {:ok, user} -> - conn - |> put_session(:user_id, user.id) - |> put_flash(:info, "You're registred!") - |> redirect(to: "/") + def sign_up(conn, %{"username" => username, "email" => email, "password" => password}) do + instance_settings = InstanceSettings.get_instance_settings!() + domain = instance_settings.domain + hashed_password = Argon2.hash_pwd_salt(password) + {publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen() + + with {:ok, actor} <- Actor.create_actor_minimal(username, domain, publicKeyPem), + {:ok, user} <- + User.create_user(%{ + id: actor.id, + email: email, + password: hashed_password, + privateKeyPem: privateKeyPem, + last_active_at: DateTime.utc_now() + }) do + conn + |> put_session(:user_id, user.id) + |> put_flash(:info, "You're registred!") + |> redirect(to: "/") + else {:error, %Ecto.Changeset{} = changeset} -> render(conn, "sign_up.html", changeset: changeset) end From 5cb937c14c6181c319806b5cc658fe38e2f474d8 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 12:16:27 +0200 Subject: [PATCH 105/144] Move auth template to page --- lib/nulla_web/components/templates.ex | 6 ------ .../components/templates/{auth => page}/sign_in.html.heex | 0 .../components/templates/{auth => page}/sign_up.html.heex | 0 3 files changed, 6 deletions(-) rename lib/nulla_web/components/templates/{auth => page}/sign_in.html.heex (100%) rename lib/nulla_web/components/templates/{auth => page}/sign_up.html.heex (100%) diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index be2365a..e8318f3 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -4,12 +4,6 @@ defmodule NullaWeb.PageHTML do embed_templates "templates/page/*" end -defmodule NullaWeb.AuthHTML do - use NullaWeb, :html - - embed_templates "templates/auth/*" -end - defmodule NullaWeb.ActorHTML do use NullaWeb, :html diff --git a/lib/nulla_web/components/templates/auth/sign_in.html.heex b/lib/nulla_web/components/templates/page/sign_in.html.heex similarity index 100% rename from lib/nulla_web/components/templates/auth/sign_in.html.heex rename to lib/nulla_web/components/templates/page/sign_in.html.heex diff --git a/lib/nulla_web/components/templates/auth/sign_up.html.heex b/lib/nulla_web/components/templates/page/sign_up.html.heex similarity index 100% rename from lib/nulla_web/components/templates/auth/sign_up.html.heex rename to lib/nulla_web/components/templates/page/sign_up.html.heex From f7f4d43a8b12d71d4f139a7c818e5d9a3a1d988d Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 12:29:33 +0200 Subject: [PATCH 106/144] Add csrf token to sign_up and sign_in templates --- lib/nulla_web/components/templates/page/sign_in.html.heex | 1 + lib/nulla_web/components/templates/page/sign_up.html.heex | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/nulla_web/components/templates/page/sign_in.html.heex b/lib/nulla_web/components/templates/page/sign_in.html.heex index 1cdbb55..225a331 100644 --- a/lib/nulla_web/components/templates/page/sign_in.html.heex +++ b/lib/nulla_web/components/templates/page/sign_in.html.heex @@ -1,5 +1,6 @@
+ diff --git a/lib/nulla_web/components/templates/page/sign_up.html.heex b/lib/nulla_web/components/templates/page/sign_up.html.heex index db4190d..5541d0e 100644 --- a/lib/nulla_web/components/templates/page/sign_up.html.heex +++ b/lib/nulla_web/components/templates/page/sign_up.html.heex @@ -1,5 +1,6 @@
+ From 5ece6bef19029554bb93c42e3ad4d2825646822a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 12:29:58 +0200 Subject: [PATCH 107/144] Update router.ex --- lib/nulla_web/router.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 500aafa..cc889c4 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -26,11 +26,11 @@ defmodule NullaWeb.Router do post "/inbox", InboxController, :inbox scope "/auth" do - get "/sign_in", PageController, :sign_in + post "/", AuthController, :sign_up post "/sign_in", AuthController, :sign_in delete "/sign_out", AuthController, :sign_out get "/sign_up", PageController, :sign_up - post "/sign_up", AuthController, :sign_up + get "/sign_in", PageController, :sign_in end scope "/users/:username" do From 987d32398b27e9500fd4e4f9de057e01c0d952cb Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 14:31:37 +0200 Subject: [PATCH 108/144] Update sign_in --- lib/nulla/models/user.ex | 4 ++- lib/nulla_web/controllers/auth_controller.ex | 26 +++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 29f3a8e..4bb596e 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -43,7 +43,9 @@ defmodule Nulla.Models.User do |> Repo.insert() end - def get_user_by_username(username), do: Repo.get_by(User, username: username) + def get_user(by) when is_map(by) or is_list(by) do + Repo.get_by(User, by) + end def get_total_users_count() do Repo.aggregate(from(u in User), :count, :id) diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index 5755775..52748e4 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -4,9 +4,24 @@ defmodule NullaWeb.AuthController do alias Nulla.Models.Actor alias Nulla.Models.InstanceSettings - def sign_in(conn, _params) do - conn - |> redirect(to: "/") + def sign_in(conn, %{"email" => email, "password" => password}) do + user = User.get_user(email: email) + + if user do + if Argon2.verify_pass(password, user.password) do + conn + |> put_session(:user_id, user.id) + |> redirect(to: "/") + else + conn + |> put_flash(:error, "Invalid login or password.") + |> redirect(to: ~p"/auth/sign_in") + end + else + conn + |> put_flash(:error, "User not exist.") + |> redirect(to: ~p"/auth/sign_in") + end end def sign_out(conn, _params) do @@ -18,6 +33,11 @@ defmodule NullaWeb.AuthController do def sign_up(conn, %{"username" => username, "email" => email, "password" => password}) do instance_settings = InstanceSettings.get_instance_settings!() + + if not instance_settings.registration do + redirect(conn, to: "/") + end + domain = instance_settings.domain hashed_password = Argon2.hash_pwd_salt(password) From 9a7d7af6937670e6c3b02ded24316dd51dcbf72a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 15:34:31 +0200 Subject: [PATCH 109/144] Update --- lib/nulla/uploader.ex | 55 +++++++++++++++---- .../components/templates/page/home.html.heex | 3 + .../templates/page/sign_in.html.heex | 2 + .../templates/page/sign_up.html.heex | 2 + lib/nulla_web/controllers/auth_controller.ex | 50 +++++++++-------- 5 files changed, 78 insertions(+), 34 deletions(-) diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex index bb18bf2..a517600 100644 --- a/lib/nulla/uploader.ex +++ b/lib/nulla/uploader.ex @@ -5,12 +5,7 @@ defmodule Nulla.Uploader do @upload_base "priv/static" @upload_prefix "system" - def upload( - %Plug.Upload{path: temp_path, filename: original_name}, - dir, - description, - domain - ) do + def upload(%Plug.Upload{path: temp_path, filename: original_name}, uploadType, name, domain) do {:ok, binary} = File.read(temp_path) ext = Path.extname(original_name) mimetype = MIME.type(ext) @@ -23,14 +18,54 @@ defmodule Nulla.Uploader do true -> "Document" end + {width, height} = + cond do + mimetype =~ "image" or "video" -> + {output, 0} = + System.cmd("ffprobe", [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "default=noprint_wrappers=1", + temp_path + ]) + + parsed_width = + Regex.run(~r/width=(\d+)/, output) + |> List.last() + |> String.to_integer() + + parsed_height = + Regex.run(~r/height=(\d+)/, output) + |> List.last() + |> String.to_integer() + + {parsed_width, parsed_height} + + true -> + {nil, nil} + end + filename = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) <> ext media_attachment_id = Snowflake.next_id() + dirs = + cond do + uploadType == :avatar -> "accounts/avatars" + uploadType == :header -> "accounts/headers" + uploadType == :attachment -> "media_attachments/files" + uploadType == :emoji -> "custom_emojis/images" + end + relative_path = Path.join([ @upload_prefix, - dir, + dirs, partition_id(media_attachment_id), "original", filename @@ -47,9 +82,9 @@ defmodule Nulla.Uploader do type: type, mediaType: mimetype, url: "https://#{domain}/#{relative_path}", - name: description, - width: 1, - height: 1 + name: name, + width: width, + height: height }) end diff --git a/lib/nulla_web/components/templates/page/home.html.heex b/lib/nulla_web/components/templates/page/home.html.heex index e69de29..2d928c6 100644 --- a/lib/nulla_web/components/templates/page/home.html.heex +++ b/lib/nulla_web/components/templates/page/home.html.heex @@ -0,0 +1,3 @@ +
+ <.flash_group flash={@flash} /> +
diff --git a/lib/nulla_web/components/templates/page/sign_in.html.heex b/lib/nulla_web/components/templates/page/sign_in.html.heex index 225a331..33ee7da 100644 --- a/lib/nulla_web/components/templates/page/sign_in.html.heex +++ b/lib/nulla_web/components/templates/page/sign_in.html.heex @@ -1,4 +1,6 @@
+ <.flash_group flash={@flash} /> + diff --git a/lib/nulla_web/components/templates/page/sign_up.html.heex b/lib/nulla_web/components/templates/page/sign_up.html.heex index 5541d0e..58b7e26 100644 --- a/lib/nulla_web/components/templates/page/sign_up.html.heex +++ b/lib/nulla_web/components/templates/page/sign_up.html.heex @@ -1,4 +1,6 @@
+ <.flash_group flash={@flash} /> + diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index 52748e4..e766e56 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -11,7 +11,7 @@ defmodule NullaWeb.AuthController do if Argon2.verify_pass(password, user.password) do conn |> put_session(:user_id, user.id) - |> redirect(to: "/") + |> redirect(to: ~p"/") else conn |> put_flash(:error, "Invalid login or password.") @@ -28,37 +28,39 @@ defmodule NullaWeb.AuthController do conn |> configure_session(drop: true) |> put_flash(:info, "You have been logged out.") - |> redirect(to: "/") + |> redirect(to: ~p"/") end def sign_up(conn, %{"username" => username, "email" => email, "password" => password}) do instance_settings = InstanceSettings.get_instance_settings!() if not instance_settings.registration do - redirect(conn, to: "/") - end - - domain = instance_settings.domain - hashed_password = Argon2.hash_pwd_salt(password) - - {publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen() - - with {:ok, actor} <- Actor.create_actor_minimal(username, domain, publicKeyPem), - {:ok, user} <- - User.create_user(%{ - id: actor.id, - email: email, - password: hashed_password, - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) do conn - |> put_session(:user_id, user.id) - |> put_flash(:info, "You're registred!") - |> redirect(to: "/") + |> put_flash(:error, "Registration is disabled.") + |> redirect(to: ~p"/") else - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "sign_up.html", changeset: changeset) + domain = instance_settings.domain + hashed_password = Argon2.hash_pwd_salt(password) + + {publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen() + + with {:ok, actor} <- Actor.create_actor_minimal(username, domain, publicKeyPem), + {:ok, user} <- + User.create_user(%{ + id: actor.id, + email: email, + password: hashed_password, + privateKeyPem: privateKeyPem, + last_active_at: DateTime.utc_now() + }) do + conn + |> put_session(:user_id, user.id) + |> put_flash(:info, "You're registred!") + |> redirect(to: ~p"/") + else + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "sign_up.html", changeset: changeset) + end end end end From 3a5311328495b9826ee19dbb03928d38b4938371 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 20:02:02 +0200 Subject: [PATCH 110/144] Update uploader.ex --- lib/nulla/uploader.ex | 97 ++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/lib/nulla/uploader.ex b/lib/nulla/uploader.ex index a517600..7aada0f 100644 --- a/lib/nulla/uploader.ex +++ b/lib/nulla/uploader.ex @@ -5,67 +5,17 @@ defmodule Nulla.Uploader do @upload_base "priv/static" @upload_prefix "system" - def upload(%Plug.Upload{path: temp_path, filename: original_name}, uploadType, name, domain) do + def upload(%Plug.Upload{path: temp_path, filename: original_name}, domain, name) do {:ok, binary} = File.read(temp_path) ext = Path.extname(original_name) mimetype = MIME.type(ext) - - type = - cond do - mimetype =~ "image" -> "Image" - mimetype =~ "video" -> "Video" - mimetype =~ "audio" -> "Audio" - true -> "Document" - end - - {width, height} = - cond do - mimetype =~ "image" or "video" -> - {output, 0} = - System.cmd("ffprobe", [ - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=width,height", - "-of", - "default=noprint_wrappers=1", - temp_path - ]) - - parsed_width = - Regex.run(~r/width=(\d+)/, output) - |> List.last() - |> String.to_integer() - - parsed_height = - Regex.run(~r/height=(\d+)/, output) - |> List.last() - |> String.to_integer() - - {parsed_width, parsed_height} - - true -> - {nil, nil} - end - filename = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) <> ext - media_attachment_id = Snowflake.next_id() - dirs = - cond do - uploadType == :avatar -> "accounts/avatars" - uploadType == :header -> "accounts/headers" - uploadType == :attachment -> "media_attachments/files" - uploadType == :emoji -> "custom_emojis/images" - end - relative_path = Path.join([ @upload_prefix, - dirs, + "media_attachments/files", partition_id(media_attachment_id), "original", filename @@ -77,6 +27,16 @@ defmodule Nulla.Uploader do File.write!(full_path, binary) + type = + cond do + mimetype =~ "image" -> "Image" + mimetype =~ "video" -> "Video" + mimetype =~ "audio" -> "Audio" + true -> "Document" + end + + {width, height} = get_width_and_height(temp_path, mimetype) + MediaAttachment.create_media_attachment(%{ id: media_attachment_id, type: type, @@ -88,6 +48,39 @@ defmodule Nulla.Uploader do }) end + defp get_width_and_height(path, mimetype) do + cond do + mimetype =~ "image" or "video" -> + {output, 0} = + System.cmd("ffprobe", [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "default=noprint_wrappers=1", + path + ]) + + width = + Regex.run(~r/width=(\d+)/, output) + |> List.last() + |> String.to_integer() + + height = + Regex.run(~r/height=(\d+)/, output) + |> List.last() + |> String.to_integer() + + {width, height} + + true -> + {nil, nil} + end + end + defp partition_id(id) do id |> Integer.to_string() From 124149129e402204428009ce2cf5be93d75ca22d Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 21 Jun 2025 20:54:31 +0200 Subject: [PATCH 111/144] Add default avatar and header --- lib/nulla_web/components/templates.ex | 16 ++++++++++++++++ .../components/templates/actor/show.html.heex | 6 +++--- priv/static/images/default-avatar.jpg | Bin 0 -> 7922 bytes priv/static/images/default-header.jpg | Bin 0 -> 9411 bytes 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 priv/static/images/default-avatar.jpg create mode 100644 priv/static/images/default-header.jpg diff --git a/lib/nulla_web/components/templates.ex b/lib/nulla_web/components/templates.ex index e8318f3..65365a4 100644 --- a/lib/nulla_web/components/templates.ex +++ b/lib/nulla_web/components/templates.ex @@ -9,6 +9,22 @@ defmodule NullaWeb.ActorHTML do embed_templates "templates/actor/*" + def avatar(url) do + if url do + url + else + "/images/default-avatar.jpg" + end + end + + def profile_header(url) do + if url do + url + else + "/images/default-header.jpg" + end + end + def format_birthdate(date) do formatted = Date.to_string(date) |> String.replace("-", "/") diff --git a/lib/nulla_web/components/templates/actor/show.html.heex b/lib/nulla_web/components/templates/actor/show.html.heex index 76d3f63..d7a2d21 100644 --- a/lib/nulla_web/components/templates/actor/show.html.heex +++ b/lib/nulla_web/components/templates/actor/show.html.heex @@ -16,10 +16,10 @@
- +
@@ -82,7 +82,7 @@ <%= for note <- @notes do %>
- +
diff --git a/priv/static/images/default-avatar.jpg b/priv/static/images/default-avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3113c7fa002e57eb1f987603c82fed140b255008 GIT binary patch literal 7922 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 Date: Sun, 22 Jun 2025 06:34:34 +0200 Subject: [PATCH 112/144] Update --- lib/nulla/httpsignature.ex | 69 ++++++++++++++++++- lib/nulla/models/actor.ex | 4 +- lib/nulla/models/user.ex | 7 +- lib/nulla/utils.ex | 33 +++------ lib/nulla_web/controllers/actor_controller.ex | 2 +- .../controllers/follow_controller.ex | 8 +-- lib/nulla_web/controllers/inbox_controller.ex | 4 +- .../controllers/outbox_controller.ex | 4 +- .../controllers/webfinger_controller.ex | 2 +- 9 files changed, 92 insertions(+), 41 deletions(-) diff --git a/lib/nulla/httpsignature.ex b/lib/nulla/httpsignature.ex index 8831606..9183604 100644 --- a/lib/nulla/httpsignature.ex +++ b/lib/nulla/httpsignature.ex @@ -1,5 +1,70 @@ defmodule Nulla.HTTPSignature do - def verify(_conn, _actor) do - :ok + import Plug.Conn + + def verify(conn, actor_json) do + with [sig_header] <- get_req_header(conn, "signature"), + signature_map <- parse_signature_header(sig_header), + {:ok, signed_string} <- build_signature_string(signature_map["headers"], conn), + {:ok, public_key_pem} <- extract_public_key(actor_json), + true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do + :ok + else + _ -> {:error, :invalid_signature} + end + end + + defp parse_signature_header(header) do + header + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {String.trim(k), String.trim(v, ~s("))} + end) + |> Enum.into(%{}) + end + + defp build_signature_string(nil, _conn), do: {:error, :missing_headers} + + defp build_signature_string(headers_str, conn) do + headers = String.split(headers_str, " ") + + result = + Enum.map(headers, fn header -> + line = + case header do + "(request-target)" -> + method = String.downcase(conn.method) + + path = + conn.request_path <> + if conn.query_string != "", do: "?" <> conn.query_string, else: "" + + "(request-target): #{method} #{path}" + + _ -> + value = get_req_header(conn, header) |> List.first() + if value, do: "#{header}: #{value}", else: nil + end + + line + end) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + {:ok, result} + end + + defp extract_public_key(%{"publicKey" => %{"publicKeyPem" => pem}}), do: {:ok, pem} + defp extract_public_key(_), do: {:error, :no_public_key} + + defp verify_signature(public_key_pem, signed_string, signature_base64) do + public_key = + :public_key.pem_decode(public_key_pem) + |> hd() + |> :public_key.pem_entry_decode() + + signature = Base.decode64!(signature_base64) + + :public_key.verify(signed_string, :sha256, signature, public_key) end end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index a4f579c..e00929b 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -138,7 +138,7 @@ defmodule Nulla.Models.Actor do |> Repo.insert() end - def get_actor(username, domain) do - Repo.get_by(__MODULE__, preferredUsername: username, domain: domain) + def get_actor(by) when is_map(by) or is_list(by) do + Repo.get_by(__MODULE__, by) end end diff --git a/lib/nulla/models/user.ex b/lib/nulla/models/user.ex index 4bb596e..9c5adeb 100644 --- a/lib/nulla/models/user.ex +++ b/lib/nulla/models/user.ex @@ -3,7 +3,6 @@ defmodule Nulla.Models.User do import Ecto.Changeset import Ecto.Query alias Nulla.Repo - alias Nulla.Models.User alias Nulla.Models.Session @primary_key {:id, :integer, autogenerate: false} @@ -44,17 +43,17 @@ defmodule Nulla.Models.User do end def get_user(by) when is_map(by) or is_list(by) do - Repo.get_by(User, by) + Repo.get_by(__MODULE__, by) end def get_total_users_count() do - Repo.aggregate(from(u in User), :count, :id) + Repo.aggregate(from(u in __MODULE__), :count, :id) end def get_active_users_count(days) do cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second) - from(u in User, where: u.last_active_at > ^cutoff) + from(u in __MODULE__, where: u.last_active_at > ^cutoff) |> Repo.aggregate(:count, :id) end diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index 4e74f08..a0454d7 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -1,28 +1,12 @@ defmodule Nulla.Utils do - alias Nulla.Models.Actor - alias Nulla.Models.InstanceSettings - - def resolve_local_actor("https://" <> _ = uri) do - case URI.parse(uri).path do - "/@" <> username -> - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - - case Actor.get_actor(username, domain) do - nil -> {:error, :not_found} - user -> user - end - - _ -> - {:error, :invalid_actor} - end - end - def fetch_remote_actor(uri) do - request = - Finch.build(:get, uri, [ - {"Accept", "application/activity+json"} - ]) + headers = [ + {"Accept", "application/activity+json"}, + {"User-Agent", "Nulla/1.0"}, + {"Host", URI.parse(uri).host} + ] + + request = Finch.build(:get, uri, headers) case Finch.request(request, Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> @@ -31,6 +15,9 @@ defmodule Nulla.Utils do _ -> {:error, :invalid_json} end + {:ok, %Finch.Response{status: code}} when code in 300..399 -> + {:error, :redirect_not_followed} + _ -> {:error, :actor_fetch_failed} end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index e497888..c4b8479 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -10,7 +10,7 @@ defmodule NullaWeb.ActorController do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - case Actor.get_actor(username, domain) do + case Actor.get_actor(preferredUsername: username, domain: domain) do nil -> conn |> put_status(:not_found) diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index ca5afee..8a21c7d 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -9,7 +9,7 @@ defmodule NullaWeb.FollowController do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain limit = instance_settings.api_limit - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_following(actor.id) page = @@ -28,7 +28,7 @@ defmodule NullaWeb.FollowController do def following(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_following(actor.id) conn @@ -40,7 +40,7 @@ defmodule NullaWeb.FollowController do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain limit = instance_settings.api_limit - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_followers(actor.id) page = @@ -59,7 +59,7 @@ defmodule NullaWeb.FollowController do def followers(conn, %{"username" => username}) do instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_followers(actor.id) conn diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 06f3689..a58ffec 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -11,7 +11,7 @@ defmodule NullaWeb.InboxController do conn, %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} ) do - with {:ok, local_actor} <- Utils.resolve_local_actor(target_uri), + with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), :ok <- HTTPSignature.verify(conn, remote_actor_json), remote_actor <- @@ -31,7 +31,7 @@ defmodule NullaWeb.InboxController do accept_activity <- Activity.create_activity(%{ type: "Accept", - actor: local_actor.id, + actor: local_actor.ap_id, object: follow_activity }), _ <- diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index dffa3d8..093015d 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -10,7 +10,7 @@ defmodule NullaWeb.OutboxController do "true" -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) max_id = params["max_id"] && String.to_integer(params["max_id"]) notes = @@ -44,7 +44,7 @@ defmodule NullaWeb.OutboxController do _ -> instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain - actor = Actor.get_actor(username, domain) + actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Note.get_total_notes_count(actor.id) conn diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index a36e6b3..3db0a28 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -7,7 +7,7 @@ defmodule NullaWeb.WebfingerController do def index(conn, %{"resource" => resource}) do case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do [_, username, domain] -> - case Actor.get_actor(username, domain) do + case Actor.get_actor(preferredUsername: username, domain: domain) do nil -> conn |> put_resp_content_type("text/plain") From 11618a6621814bea3a86c7eddc81d0f0e577e9cf Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 22 Jun 2025 07:27:19 +0200 Subject: [PATCH 113/144] Update follow activity --- lib/nulla/activitypub.ex | 11 ---- lib/nulla_web/controllers/inbox_controller.ex | 57 +++++++++++++++++-- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 0787fc5..21f5507 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -349,15 +349,4 @@ defmodule Nulla.ActivityPub do ) ) end - - @spec follow_accept(Activity.t()) :: Jason.OrderedObject.t() - def follow_accept(activity) do - Jason.OrderedObject.new( - "@context": "https://www.w3.org/ns/activitystreams", - id: activity.ap_id, - type: activity.type, - actor: activity.actor, - object: activity.object - ) - end end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index a58ffec..2ca8159 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -22,14 +22,14 @@ defmodule NullaWeb.InboxController do |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) ), follow_activity <- - Activity.create_activity(%{ + Activity.activity(%{ ap_id: follow_id, type: "Follow", actor: remote_actor.id, object: target_uri }), accept_activity <- - Activity.create_activity(%{ + Activity.activity(%{ type: "Accept", actor: local_actor.ap_id, object: follow_activity @@ -40,12 +40,57 @@ defmodule NullaWeb.InboxController do local_actor_id: local_actor.id, remote_actor_id: remote_actor.id }) do + body = Jason.encode!(ActivityPub.follow_accept(accept_activity)) + + digest = + :crypto.hash(:sha256, body) + |> Base.encode64() + |> then(&("SHA-256=" <> &1)) + + date = + DateTime.utc_now() + |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + + host = URI.parse(actor_uri).host + request_target = "post /inbox" + + signature_string = + """ + (request-target): #{request_target} + host: #{host} + date: #{date} + digest: #{digest} + """ + + user = User.get_user(id: local_actor.id) + privateKeyPem = user.privateKey["privateKeyPem"] + + private_key = + :public_key.pem_decode(local_actor.private_key_pem) + |> hd() + |> :public_key.pem_entry_decode() + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{local_actor.publicKey["id"]}", + algorithm="rsa-sha256", + headers="(request-target) host date digest content-type", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + conn |> put_resp_content_type("application/activity+json") - |> send_resp( - 200, - Jason.encode!(ActivityPub.follow_accept(accept_activity)) - ) + |> put_resp_header("host", host) + |> put_resp_header("date", date) + |> put_resp_header("signature", signature_header) + |> put_resp_header("digest", digest) + |> send_resp(200, body) else error -> IO.inspect(error, label: "Follow error") From aac9bcb6e48ec033758bbae0849a4bcb2fb5987d Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 22 Jun 2025 07:46:59 +0200 Subject: [PATCH 114/144] Fix inbox_controller.ex --- lib/nulla_web/controllers/inbox_controller.ex | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 2ca8159..ef3c9a3 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -2,7 +2,9 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller alias Nulla.HTTPSignature alias Nulla.ActivityPub + alias Nulla.Snowflake alias Nulla.Utils + alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Activity @@ -11,6 +13,8 @@ defmodule NullaWeb.InboxController do conn, %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} ) do + accept_id = Snowflake.next_id() + with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), :ok <- HTTPSignature.verify(conn, remote_actor_json), @@ -22,14 +26,16 @@ defmodule NullaWeb.InboxController do |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) ), follow_activity <- - Activity.activity(%{ + Activity.create_activity(%{ ap_id: follow_id, type: "Follow", actor: remote_actor.id, object: target_uri }), accept_activity <- - Activity.activity(%{ + Activity.create_activity(%{ + id: accept_id, + ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", type: "Accept", actor: local_actor.ap_id, object: follow_activity @@ -40,7 +46,7 @@ defmodule NullaWeb.InboxController do local_actor_id: local_actor.id, remote_actor_id: remote_actor.id }) do - body = Jason.encode!(ActivityPub.follow_accept(accept_activity)) + body = Jason.encode!(ActivityPub.activity(accept_activity)) digest = :crypto.hash(:sha256, body) @@ -66,7 +72,7 @@ defmodule NullaWeb.InboxController do privateKeyPem = user.privateKey["privateKeyPem"] private_key = - :public_key.pem_decode(local_actor.private_key_pem) + :public_key.pem_decode(privateKeyPem) |> hd() |> :public_key.pem_entry_decode() From 3f329cf59e8465081bbef0ed7dacb19c21720d9a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 23 Jun 2025 09:16:27 +0000 Subject: [PATCH 115/144] Update --- config/dev.exs | 6 +- lib/nulla/activitypub.ex | 7 +- lib/nulla/keygen.ex | 25 +- lib/nulla/models/activity.ex | 6 +- lib/nulla/models/actor.ex | 7 - lib/nulla/models/relation.ex | 6 +- lib/nulla/utils.ex | 4 +- lib/nulla_web/controllers/actor_controller.ex | 2 +- .../controllers/follow_controller.ex | 4 +- lib/nulla_web/controllers/inbox_controller.ex | 227 +++++++++++------- lib/nulla_web/router.ex | 6 +- .../20250615130714_create_actors.exs | 2 +- .../20250615131856_create_activities.exs | 8 +- 13 files changed, 191 insertions(+), 119 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index 31508fc..b9f3112 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,10 +16,14 @@ config :nulla, Nulla.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. +host = System.get_env("PHX_HOST") || "example.com" +port = String.to_integer(System.get_env("PORT") || "4000") + config :nulla, NullaWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + url: [host: host, port: port], + http: [ip: {127, 0, 0, 1}, port: port], check_origin: false, code_reloader: true, debug_errors: true, diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 21f5507..e746002 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -51,7 +51,12 @@ defmodule Nulla.ActivityPub do indexable: actor.indexable, published: DateTime.to_iso8601(actor.published), memorial: actor.memorial, - publicKey: actor.publicKey, + publicKey: + Jason.OrderedObject.new( + id: actor.publicKey["id"], + owner: actor.publicKey["owner"], + publicKeyPem: actor.publicKey["publicKeyPem"] + ), tag: actor.tag, attachment: actor.attachment, endpoints: actor.endpoints, diff --git a/lib/nulla/keygen.ex b/lib/nulla/keygen.ex index 5cbaf06..8a75cb5 100644 --- a/lib/nulla/keygen.ex +++ b/lib/nulla/keygen.ex @@ -1,18 +1,23 @@ defmodule Nulla.KeyGen do def gen do - rsa_key = :public_key.generate_key({:rsa, 2048, 65537}) + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - {:RSAPrivateKey, :"two-prime", n, e, _d, _p, _q, _dp, _dq, _qi, _other} = rsa_key - public_key = {:RSAPublicKey, n, e} + private_pem = + :public_key.pem_encode([entry]) + |> String.trim_trailing() + |> Kernel.<>("\n") - private_entry = - {:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} + [private_key_code] = :public_key.pem_decode(private_pem) + private_key = :public_key.pem_entry_decode(private_key_code) + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) - public_entry = - {:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} - - private_pem = :public_key.pem_encode([private_entry]) - public_pem = :public_key.pem_encode([public_entry]) + public_pem = + :public_key.pem_encode([public_key]) + |> String.trim_trailing() + |> Kernel.<>("\n") {public_pem, private_pem} end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index 71a192d..d09bb89 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -4,13 +4,13 @@ defmodule Nulla.Models.Activity do alias Nulla.Repo alias Nulla.Snowflake + @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} @primary_key {:id, :integer, autogenerate: false} schema "activities" do field :ap_id, :string field :type, :string field :actor, :string - field :object, :map - field :cc, {:array, :string}, default: [] + field :object, :string timestamps() end @@ -18,7 +18,7 @@ defmodule Nulla.Models.Activity do @doc false def changeset(activity, attrs) do activity - |> cast(attrs, [:ap_id, :type, :actor, :object, :to]) + |> cast(attrs, [:ap_id, :type, :actor, :object]) |> validate_required([:ap_id, :type, :actor, :object]) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index e00929b..a8b8072 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -78,15 +78,8 @@ defmodule Nulla.Models.Actor do :followers, :inbox, :outbox, - :featured, - :featuredTags, :preferredUsername, :url, - :manuallyApprovesFollowers, - :discoverable, - :indexable, - :published, - :memorial, :publicKey, :endpoints ]) diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 600abca..609ff0d 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -48,7 +48,7 @@ defmodule Nulla.Models.Relation do :local_actor_id, :remote_actor_id ]) - |> validate_required([:id, :local_actor_id, :remote_actor_id]) + |> validate_required([:local_actor_id, :remote_actor_id]) |> unique_constraint([:local_actor_id, :remote_actor_id]) end @@ -61,6 +61,10 @@ defmodule Nulla.Models.Relation do |> Repo.insert() end + def get_relation(by) when is_map(by) or is_list(by) do + Repo.get_by(__MODULE__, by) + end + def count_following(local_actor_id) do __MODULE__ |> where([r], r.local_actor_id == ^local_actor_id and r.following == true) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index a0454d7..4915620 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -1,4 +1,6 @@ defmodule Nulla.Utils do + alias Finch + def fetch_remote_actor(uri) do headers = [ {"Accept", "application/activity+json"}, @@ -8,7 +10,7 @@ defmodule Nulla.Utils do request = Finch.build(:get, uri, headers) - case Finch.request(request, Finch) do + case Finch.request(request, Nulla.Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> case Jason.decode(body) do {:ok, data} -> {:ok, data} diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index c4b8479..8e88551 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do |> json(%{error: "Not Found"}) %Actor{} = actor -> - if accept in ["application/activity+json", "application/ld+json"] do + if accept =~ "json" do conn |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index 8a21c7d..d8ca5b4 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -18,7 +18,7 @@ defmodule NullaWeb.FollowController do _ -> 1 end - following_list = Relation.get_following(actor.id, page, limit) + following_list = Enum.map(Relation.get_following(actor.id, page, limit), & &1.ap_id) conn |> put_resp_content_type("application/activity+json") @@ -49,7 +49,7 @@ defmodule NullaWeb.FollowController do _ -> 1 end - followers_list = Relation.get_followers(actor.id, page, limit) + followers_list = Enum.map(Relation.get_followers(actor.id, page, limit), & &1.ap_id) conn |> put_resp_content_type("application/activity+json") diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index ef3c9a3..6bdecd3 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,106 +1,165 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller - alias Nulla.HTTPSignature alias Nulla.ActivityPub alias Nulla.Snowflake + alias Nulla.HTTPSignature alias Nulla.Utils alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Activity - def inbox( - conn, - %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} - ) do + def inbox(conn, %{ + "id" => follow_id, + "type" => "Follow", + "actor" => actor_uri, + "object" => target_uri + }) do accept_id = Snowflake.next_id() with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), :ok <- HTTPSignature.verify(conn, remote_actor_json), - remote_actor <- - Actor.create_actor( - remote_actor_json - |> Map.put("ap_id", remote_actor_json["id"]) - |> Map.delete("id") - |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) - ), - follow_activity <- - Activity.create_activity(%{ - ap_id: follow_id, - type: "Follow", - actor: remote_actor.id, - object: target_uri - }), - accept_activity <- - Activity.create_activity(%{ - id: accept_id, - ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", - type: "Accept", - actor: local_actor.ap_id, - object: follow_activity - }), - _ <- - Relation.create_relation(%{ - followed_by: true, - local_actor_id: local_actor.id, - remote_actor_id: remote_actor.id - }) do - body = Jason.encode!(ActivityPub.activity(accept_activity)) - - digest = - :crypto.hash(:sha256, body) - |> Base.encode64() - |> then(&("SHA-256=" <> &1)) - - date = - DateTime.utc_now() - |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") - - host = URI.parse(actor_uri).host - request_target = "post /inbox" - - signature_string = - """ - (request-target): #{request_target} - host: #{host} - date: #{date} - digest: #{digest} - """ - - user = User.get_user(id: local_actor.id) - privateKeyPem = user.privateKey["privateKeyPem"] - - private_key = - :public_key.pem_decode(privateKeyPem) - |> hd() - |> :public_key.pem_entry_decode() - - signature = - :public_key.sign(signature_string, :sha256, private_key) - |> Base.encode64() - - signature_header = - """ - keyId="#{local_actor.publicKey["id"]}", - algorithm="rsa-sha256", - headers="(request-target) host date digest content-type", - signature="#{signature}" - """ - |> String.replace("\n", "") - |> String.trim() - - conn - |> put_resp_content_type("application/activity+json") - |> put_resp_header("host", host) - |> put_resp_header("date", date) - |> put_resp_header("signature", signature_header) - |> put_resp_header("digest", digest) - |> send_resp(200, body) + {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), + {:ok, follow_activity} <- + create_follow_activity(follow_id, remote_actor.ap_id, target_uri), + {:ok, accept_activity} <- + create_accept_activity(accept_id, local_actor, follow_activity), + {:ok, _relation} <- get_or_create_relation(local_actor.id, remote_actor.id), + :ok <- deliver_accept(accept_activity, remote_actor_json, local_actor) do + send_resp(conn, 200, "") else error -> IO.inspect(error, label: "Follow error") json(conn, %{"error" => "Failed to process Follow"}) end end + + defp get_or_create_actor(remote_actor_json) do + ap_id = remote_actor_json["id"] + + case Actor.get_actor(ap_id: ap_id) do + nil -> + params = + remote_actor_json + |> Map.put("ap_id", ap_id) + |> Map.delete("id") + |> Map.put("domain", URI.parse(ap_id).host) + + case Actor.create_actor(params) do + {:ok, actor} -> {:ok, actor} + {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} + end + + actor -> + {:ok, actor} + end + end + + defp create_follow_activity(follow_id, actor_id, target_uri) do + Activity.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: actor_id, + object: target_uri + }) + end + + defp create_accept_activity(accept_id, local_actor, follow_activity) do + Activity.create_activity(%{ + id: accept_id, + ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", + type: "Accept", + actor: local_actor.ap_id, + object: Jason.encode!(follow_activity) + }) + end + + defp get_or_create_relation(local_actor_id, remote_actor_id) do + case Relation.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do + nil -> + Relation.create_relation(%{ + followed_by: true, + local_actor_id: local_actor_id, + remote_actor_id: remote_actor_id + }) + + relation -> + {:ok, relation} + end + end + + defp deliver_accept(accept_activity, remote_actor_json, local_actor) do + inbox_url = remote_actor_json["inbox"] + accept_activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} + body = Jason.encode!(ActivityPub.activity(accept_activity)) + + digest = + :crypto.hash(:sha256, body) + |> Base.encode64() + |> then(&("SHA-256=" <> &1)) + + date = + DateTime.utc_now() + |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + + uri = URI.parse(inbox_url) + request_target = "post #{uri.path}" + + signature_string = """ + (request-target): #{request_target} + host: #{uri.host} + date: #{date} + digest: #{digest} + """ + + user = User.get_user(id: local_actor.id) + + private_key = + case :public_key.pem_decode(user.privateKeyPem) do + [{_type, der, _rest}] -> + :public_key.der_decode(:RSAPrivateKey, der) + + _ -> + raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{local_actor.publicKey["id"]}", + algorithm="rsa-sha256", + headers="(request-target) host date digest content-type", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + headers = [ + {"host", uri.host}, + {"date", date}, + {"digest", digest}, + {"signature", signature_header}, + {"content-type", "application/activity+json"} + ] + + request = Finch.build(:post, inbox_url, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Accept delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver Accept") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Finch delivery failed") + {:error, reason} + end + end end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index cc889c4..53d3633 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -2,12 +2,12 @@ defmodule NullaWeb.Router do use NullaWeb, :router pipeline :browser do - plug :accepts, ["html", "activity+json", "ld+json"] + plug :accepts, ["html", "json", "activity+json", "ld+json"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers + # plug :protect_from_forgery + # plug :put_secure_browser_headers end pipeline :api do diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs index 116a120..bb403fd 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -15,7 +15,7 @@ defmodule Nulla.Repo.Migrations.CreateActors do add :featuredTags, :string add :preferredUsername, :string, null: false add :name, :string - add :summary, :string + add :summary, :text add :url, :string add :manuallyApprovesFollowers, :boolean add :discoverable, :boolean, default: true diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index b07c822..965df8f 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -6,14 +6,14 @@ defmodule Nulla.Repo.Migrations.CreateActivities do add :id, :bigint, primary_key: true add :ap_id, :string, null: false add :type, :string, null: false - add :actor_id, references(:actors, type: :bigint, on_delete: :nothing), null: false - add :object, :map, null: false - add :to, {:array, :string}, default: [] + add :actor, :string, null: false + add :object, :text, null: false timestamps() end + create index(:activities, [:ap_id]) create index(:activities, [:type]) - create index(:activities, [:actor_id]) + create index(:activities, [:actor]) end end From 2b5b658809118ced47d2006786cb0cb129fd508a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 24 Jun 2025 07:01:23 +0000 Subject: [PATCH 116/144] Update --- lib/nulla/httpsignature.ex | 6 +- lib/nulla_web/controllers/inbox_controller.ex | 111 ++++++++++-------- 2 files changed, 64 insertions(+), 53 deletions(-) diff --git a/lib/nulla/httpsignature.ex b/lib/nulla/httpsignature.ex index 9183604..dad2d7f 100644 --- a/lib/nulla/httpsignature.ex +++ b/lib/nulla/httpsignature.ex @@ -1,11 +1,10 @@ defmodule Nulla.HTTPSignature do import Plug.Conn - def verify(conn, actor_json) do + def verify(conn, public_key_pem) do with [sig_header] <- get_req_header(conn, "signature"), signature_map <- parse_signature_header(sig_header), {:ok, signed_string} <- build_signature_string(signature_map["headers"], conn), - {:ok, public_key_pem} <- extract_public_key(actor_json), true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do :ok else @@ -54,9 +53,6 @@ defmodule Nulla.HTTPSignature do {:ok, result} end - defp extract_public_key(%{"publicKey" => %{"publicKeyPem" => pem}}), do: {:ok, pem} - defp extract_public_key(_), do: {:error, :no_public_key} - defp verify_signature(public_key_pem, signed_string, signature_base64) do public_key = :public_key.pem_decode(public_key_pem) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 6bdecd3..052f7d7 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -19,14 +19,25 @@ defmodule NullaWeb.InboxController do with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), - :ok <- HTTPSignature.verify(conn, remote_actor_json), + :ok <- HTTPSignature.verify(conn, remote_actor_json["publicKey"]["publicKeyPem"]), {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), {:ok, follow_activity} <- - create_follow_activity(follow_id, remote_actor.ap_id, target_uri), + Activity.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: remote_actor.ap_id, + object: target_uri + }), {:ok, accept_activity} <- - create_accept_activity(accept_id, local_actor, follow_activity), + Activity.create_activity(%{ + id: accept_id, + ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", + type: "Accept", + actor: local_actor.ap_id, + object: Jason.encode!(follow_activity) + }), {:ok, _relation} <- get_or_create_relation(local_actor.id, remote_actor.id), - :ok <- deliver_accept(accept_activity, remote_actor_json, local_actor) do + :ok <- deliver_accept(accept_activity, remote_actor_json["inbox"], local_actor) do send_resp(conn, 200, "") else error -> @@ -56,25 +67,6 @@ defmodule NullaWeb.InboxController do end end - defp create_follow_activity(follow_id, actor_id, target_uri) do - Activity.create_activity(%{ - ap_id: follow_id, - type: "Follow", - actor: actor_id, - object: target_uri - }) - end - - defp create_accept_activity(accept_id, local_actor, follow_activity) do - Activity.create_activity(%{ - id: accept_id, - ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", - type: "Accept", - actor: local_actor.ap_id, - object: Jason.encode!(follow_activity) - }) - end - defp get_or_create_relation(local_actor_id, remote_actor_id) do case Relation.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do nil -> @@ -89,25 +81,15 @@ defmodule NullaWeb.InboxController do end end - defp deliver_accept(accept_activity, remote_actor_json, local_actor) do - inbox_url = remote_actor_json["inbox"] + defp deliver_accept(accept_activity, inbox_url, local_actor) do accept_activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} body = Jason.encode!(ActivityPub.activity(accept_activity)) - - digest = - :crypto.hash(:sha256, body) - |> Base.encode64() - |> then(&("SHA-256=" <> &1)) - - date = - DateTime.utc_now() - |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") - + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") uri = URI.parse(inbox_url) - request_target = "post #{uri.path}" signature_string = """ - (request-target): #{request_target} + (request-target): post #{uri.path} host: #{uri.host} date: #{date} digest: #{digest} @@ -117,11 +99,8 @@ defmodule NullaWeb.InboxController do private_key = case :public_key.pem_decode(user.privateKeyPem) do - [{_type, der, _rest}] -> - :public_key.der_decode(:RSAPrivateKey, der) - - _ -> - raise "Invalid PEM format" + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" end signature = @@ -132,18 +111,17 @@ defmodule NullaWeb.InboxController do """ keyId="#{local_actor.publicKey["id"]}", algorithm="rsa-sha256", - headers="(request-target) host date digest content-type", + headers="(request-target) host date digest", signature="#{signature}" """ |> String.replace("\n", "") |> String.trim() headers = [ - {"host", uri.host}, - {"date", date}, - {"digest", digest}, - {"signature", signature_header}, - {"content-type", "application/activity+json"} + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"Digest", digest}, + {"Signature", signature_header} ] request = Finch.build(:post, inbox_url, headers, body) @@ -162,4 +140,41 @@ defmodule NullaWeb.InboxController do {:error, reason} end end + + def inbox(conn, %{ + "id" => accept_id, + "type" => "Accept", + "actor" => actor_uri, + "object" => object + }) do + with {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), + :ok <- HTTPSignature.verify(conn, remote_actor_json), + {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), + {:ok, follow_activity} <- decode_follow_activity(object), + {:ok, local_actor} <- Actor.get_actor(ap_id: follow_activity["object"]), + {:ok, _relation} <- Relation.mark_follow_accepted(local_actor.id, remote_actor.id) do + send_resp(conn, 200, "") + else + error -> + IO.inspect(error, label: "Accept error") + json(conn, %{"error" => "Failed to process Accept"}) + end + end + + defp decode_follow_activity(object) when is_map(object), do: {:ok, object} + defp decode_follow_activity(object) when is_binary(object), do: Jason.decode(object) + + def inbox(conn, %{ + "id" => accept_id, + "type" => "Undo", + "actor" => actor_uri, + "object" => object + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, params) do + IO.inspect(params) + send_resp(conn, 400, "") + end end From 8402909bb57cfc5ddfdbc78ad8409e5951b65a2a Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 24 Jun 2025 07:31:28 +0000 Subject: [PATCH 117/144] Update --- lib/nulla_web/controllers/inbox_controller.ex | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 052f7d7..a787c55 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -140,41 +140,4 @@ defmodule NullaWeb.InboxController do {:error, reason} end end - - def inbox(conn, %{ - "id" => accept_id, - "type" => "Accept", - "actor" => actor_uri, - "object" => object - }) do - with {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), - :ok <- HTTPSignature.verify(conn, remote_actor_json), - {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), - {:ok, follow_activity} <- decode_follow_activity(object), - {:ok, local_actor} <- Actor.get_actor(ap_id: follow_activity["object"]), - {:ok, _relation} <- Relation.mark_follow_accepted(local_actor.id, remote_actor.id) do - send_resp(conn, 200, "") - else - error -> - IO.inspect(error, label: "Accept error") - json(conn, %{"error" => "Failed to process Accept"}) - end - end - - defp decode_follow_activity(object) when is_map(object), do: {:ok, object} - defp decode_follow_activity(object) when is_binary(object), do: Jason.decode(object) - - def inbox(conn, %{ - "id" => accept_id, - "type" => "Undo", - "actor" => actor_uri, - "object" => object - }) do - send_resp(conn, 200, "") - end - - def inbox(conn, params) do - IO.inspect(params) - send_resp(conn, 400, "") - end end From 110db96098bc6c473d9d3dce5a68b6b1eb6630ff Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 24 Jun 2025 07:36:26 +0000 Subject: [PATCH 118/144] Fix actor_controller.ex --- lib/nulla_web/controllers/actor_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index 8e88551..c4b8479 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do |> json(%{error: "Not Found"}) %Actor{} = actor -> - if accept =~ "json" do + if accept in ["application/activity+json", "application/ld+json"] do conn |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) From 93dff124a11a5c64a3dfe805c2c554d5c09ab750 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 25 Jun 2025 19:45:33 +0200 Subject: [PATCH 119/144] Update README.md --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 604408f..124cf6f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,76 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin ## TODO -Stack: Elixir + Phoenix + PostgreSQL +### API + +#### accounts + +- [] POST [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#create) +- [] GET [/api/v1/accounts/verify_credentials](https://docs.joinmastodon.org/methods/accounts/#verify_credentials) +- [] PATCH [/api/v1/accounts/update_credentials](https://docs.joinmastodon.org/methods/accounts/#update_credentials) +- [] GET [/api/v1/accounts/:id](https://docs.joinmastodon.org/methods/accounts/#get) +- [] GET [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#index) +- [] GET [/api/v1/accounts/:id/notes](https://docs.joinmastodon.org/methods/accounts/#statuses) +- [] GET [/api/v1/accounts/:id/followers](https://docs.joinmastodon.org/methods/accounts/#followers) +- [] GET [/api/v1/accounts/:id/following](https://docs.joinmastodon.org/methods/accounts/#following) +- [] GET [/api/v1/accounts/:id/featured_tags](https://docs.joinmastodon.org/methods/accounts/#featured_tags) +- [] GET [/api/v1/accounts/:id/lists](https://docs.joinmastodon.org/methods/accounts/#lists) +- [] POST [/api/v1/accounts/:id/follow](https://docs.joinmastodon.org/methods/accounts/#follow) +- [] POST [/api/v1/accounts/:id/unfollow](https://docs.joinmastodon.org/methods/accounts/#unfollow) +- [] POST [/api/v1/accounts/:id/remove_from_followers](https://docs.joinmastodon.org/methods/accounts/#remove_from_followers) +- [] POST [/api/v1/accounts/:id/block](https://docs.joinmastodon.org/methods/accounts/#block) +- [] POST [/api/v1/accounts/:id/unblock](https://docs.joinmastodon.org/methods/accounts/#unblock) +- [] POST [/api/v1/accounts/:id/mute](https://docs.joinmastodon.org/methods/accounts/#mute) +- [] POST [/api/v1/accounts/:id/unmute](https://docs.joinmastodon.org/methods/accounts/#unmute) +- [] GET [/api/v1/accounts/:id/endorsements](https://docs.joinmastodon.org/methods/accounts/#endorsements) +- [] POST [/api/v1/accounts/:id/endorse](https://docs.joinmastodon.org/methods/accounts/#endorsements) +- [] POST [/api/v1/accounts/:id/unendorse](https://docs.joinmastodon.org/methods/accounts/#unendorse) +- [] POST [/api/v1/accounts/:id/note](https://docs.joinmastodon.org/methods/accounts/#note) +- [] GET [/api/v1/accounts/relationships](https://docs.joinmastodon.org/methods/accounts/#relationships) +- [] GET [/api/v1/accounts/familiar_followers](https://docs.joinmastodon.org/methods/accounts/#familiar_followers) +- [] GET [/api/v1/accounts/search](https://docs.joinmastodon.org/methods/accounts/#search) +- [] GET [/api/v1/accounts/lookup](https://docs.joinmastodon.org/methods/accounts/#lookup) + +#### announcements + +- [] GET [/api/v1/announcements](https://docs.joinmastodon.org/methods/announcements/#get) +- [] POST [/api/v1/announcements/:id/dismiss](https://docs.joinmastodon.org/methods/announcements/#dismiss) +- [] PUT [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#put-reactions) +- [] DELETE [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#delete-reactions) + +#### blocks + +- [] GET [/api/v1/blocks](https://docs.joinmastodon.org/methods/blocks/#get) + +#### bookmarks + +- [] GET [/api/v1/bookmarks](https://docs.joinmastodon.org/methods/bookmarks/#get) + +#### conversations + +- [] GET [/api/v1/conversations](https://docs.joinmastodon.org/methods/converstions/#get) +- [] DELETE [/api/v1/conversations/:id](https://docs.joinmastodon.org/methods/converstions/#delete) +- [] POST [/api/v1/conversations/:id/read](https://docs.joinmastodon.org/methods/converstions/#read) + +#### custom_emojis + +- [] GET [/api/v1/custom_emojis](https://docs.joinmastodon.org/methods/custom_emojis/#get) + +#### directory + +- [] GET [/api/v1/directory](https://docs.joinmastodon.org/methods/directory/#get) + +#### domain_blocks + +- [] GET [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#get) +- [] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#block) +- [] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#unblock) + +#### emails + +- [] POST [/api/v1/emails/confirmations](https://docs.joinmastodon.org/methods/emails/#confirmation) + +### Features - [ ] Lightweight web interface - [ ] API compatible with other ActivityPub instances From 4ba44220502cfd7f61d8735b8112a421592e9cb7 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Wed, 25 Jun 2025 19:47:10 +0200 Subject: [PATCH 120/144] Fix README.md --- README.md | 80 +++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 124cf6f..ba66884 100644 --- a/README.md +++ b/README.md @@ -8,70 +8,70 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin #### accounts -- [] POST [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#create) -- [] GET [/api/v1/accounts/verify_credentials](https://docs.joinmastodon.org/methods/accounts/#verify_credentials) -- [] PATCH [/api/v1/accounts/update_credentials](https://docs.joinmastodon.org/methods/accounts/#update_credentials) -- [] GET [/api/v1/accounts/:id](https://docs.joinmastodon.org/methods/accounts/#get) -- [] GET [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#index) -- [] GET [/api/v1/accounts/:id/notes](https://docs.joinmastodon.org/methods/accounts/#statuses) -- [] GET [/api/v1/accounts/:id/followers](https://docs.joinmastodon.org/methods/accounts/#followers) -- [] GET [/api/v1/accounts/:id/following](https://docs.joinmastodon.org/methods/accounts/#following) -- [] GET [/api/v1/accounts/:id/featured_tags](https://docs.joinmastodon.org/methods/accounts/#featured_tags) -- [] GET [/api/v1/accounts/:id/lists](https://docs.joinmastodon.org/methods/accounts/#lists) -- [] POST [/api/v1/accounts/:id/follow](https://docs.joinmastodon.org/methods/accounts/#follow) -- [] POST [/api/v1/accounts/:id/unfollow](https://docs.joinmastodon.org/methods/accounts/#unfollow) -- [] POST [/api/v1/accounts/:id/remove_from_followers](https://docs.joinmastodon.org/methods/accounts/#remove_from_followers) -- [] POST [/api/v1/accounts/:id/block](https://docs.joinmastodon.org/methods/accounts/#block) -- [] POST [/api/v1/accounts/:id/unblock](https://docs.joinmastodon.org/methods/accounts/#unblock) -- [] POST [/api/v1/accounts/:id/mute](https://docs.joinmastodon.org/methods/accounts/#mute) -- [] POST [/api/v1/accounts/:id/unmute](https://docs.joinmastodon.org/methods/accounts/#unmute) -- [] GET [/api/v1/accounts/:id/endorsements](https://docs.joinmastodon.org/methods/accounts/#endorsements) -- [] POST [/api/v1/accounts/:id/endorse](https://docs.joinmastodon.org/methods/accounts/#endorsements) -- [] POST [/api/v1/accounts/:id/unendorse](https://docs.joinmastodon.org/methods/accounts/#unendorse) -- [] POST [/api/v1/accounts/:id/note](https://docs.joinmastodon.org/methods/accounts/#note) -- [] GET [/api/v1/accounts/relationships](https://docs.joinmastodon.org/methods/accounts/#relationships) -- [] GET [/api/v1/accounts/familiar_followers](https://docs.joinmastodon.org/methods/accounts/#familiar_followers) -- [] GET [/api/v1/accounts/search](https://docs.joinmastodon.org/methods/accounts/#search) -- [] GET [/api/v1/accounts/lookup](https://docs.joinmastodon.org/methods/accounts/#lookup) +- [ ] POST [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#create) +- [ ] GET [/api/v1/accounts/verify_credentials](https://docs.joinmastodon.org/methods/accounts/#verify_credentials) +- [ ] PATCH [/api/v1/accounts/update_credentials](https://docs.joinmastodon.org/methods/accounts/#update_credentials) +- [ ] GET [/api/v1/accounts/:id](https://docs.joinmastodon.org/methods/accounts/#get) +- [ ] GET [/api/v1/accounts](https://docs.joinmastodon.org/methods/accounts/#index) +- [ ] GET [/api/v1/accounts/:id/notes](https://docs.joinmastodon.org/methods/accounts/#statuses) +- [ ] GET [/api/v1/accounts/:id/followers](https://docs.joinmastodon.org/methods/accounts/#followers) +- [ ] GET [/api/v1/accounts/:id/following](https://docs.joinmastodon.org/methods/accounts/#following) +- [ ] GET [/api/v1/accounts/:id/featured_tags](https://docs.joinmastodon.org/methods/accounts/#featured_tags) +- [ ] GET [/api/v1/accounts/:id/lists](https://docs.joinmastodon.org/methods/accounts/#lists) +- [ ] POST [/api/v1/accounts/:id/follow](https://docs.joinmastodon.org/methods/accounts/#follow) +- [ ] POST [/api/v1/accounts/:id/unfollow](https://docs.joinmastodon.org/methods/accounts/#unfollow) +- [ ] POST [/api/v1/accounts/:id/remove_from_followers](https://docs.joinmastodon.org/methods/accounts/#remove_from_followers) +- [ ] POST [/api/v1/accounts/:id/block](https://docs.joinmastodon.org/methods/accounts/#block) +- [ ] POST [/api/v1/accounts/:id/unblock](https://docs.joinmastodon.org/methods/accounts/#unblock) +- [ ] POST [/api/v1/accounts/:id/mute](https://docs.joinmastodon.org/methods/accounts/#mute) +- [ ] POST [/api/v1/accounts/:id/unmute](https://docs.joinmastodon.org/methods/accounts/#unmute) +- [ ] GET [/api/v1/accounts/:id/endorsements](https://docs.joinmastodon.org/methods/accounts/#endorsements) +- [ ] POST [/api/v1/accounts/:id/endorse](https://docs.joinmastodon.org/methods/accounts/#endorsements) +- [ ] POST [/api/v1/accounts/:id/unendorse](https://docs.joinmastodon.org/methods/accounts/#unendorse) +- [ ] POST [/api/v1/accounts/:id/note](https://docs.joinmastodon.org/methods/accounts/#note) +- [ ] GET [/api/v1/accounts/relationships](https://docs.joinmastodon.org/methods/accounts/#relationships) +- [ ] GET [/api/v1/accounts/familiar_followers](https://docs.joinmastodon.org/methods/accounts/#familiar_followers) +- [ ] GET [/api/v1/accounts/search](https://docs.joinmastodon.org/methods/accounts/#search) +- [ ] GET [/api/v1/accounts/lookup](https://docs.joinmastodon.org/methods/accounts/#lookup) #### announcements -- [] GET [/api/v1/announcements](https://docs.joinmastodon.org/methods/announcements/#get) -- [] POST [/api/v1/announcements/:id/dismiss](https://docs.joinmastodon.org/methods/announcements/#dismiss) -- [] PUT [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#put-reactions) -- [] DELETE [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#delete-reactions) +- [ ] GET [/api/v1/announcements](https://docs.joinmastodon.org/methods/announcements/#get) +- [ ] POST [/api/v1/announcements/:id/dismiss](https://docs.joinmastodon.org/methods/announcements/#dismiss) +- [ ] PUT [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#put-reactions) +- [ ] DELETE [/api/v1/announcements/:id/reactions/:name](https://docs.joinmastodon.org/methods/announcements/#delete-reactions) #### blocks -- [] GET [/api/v1/blocks](https://docs.joinmastodon.org/methods/blocks/#get) +- [ ] GET [/api/v1/blocks](https://docs.joinmastodon.org/methods/blocks/#get) #### bookmarks -- [] GET [/api/v1/bookmarks](https://docs.joinmastodon.org/methods/bookmarks/#get) +- [ ] GET [/api/v1/bookmarks](https://docs.joinmastodon.org/methods/bookmarks/#get) #### conversations -- [] GET [/api/v1/conversations](https://docs.joinmastodon.org/methods/converstions/#get) -- [] DELETE [/api/v1/conversations/:id](https://docs.joinmastodon.org/methods/converstions/#delete) -- [] POST [/api/v1/conversations/:id/read](https://docs.joinmastodon.org/methods/converstions/#read) +- [ ] GET [/api/v1/conversations](https://docs.joinmastodon.org/methods/converstions/#get) +- [ ] DELETE [/api/v1/conversations/:id](https://docs.joinmastodon.org/methods/converstions/#delete) +- [ ] POST [/api/v1/conversations/:id/read](https://docs.joinmastodon.org/methods/converstions/#read) #### custom_emojis -- [] GET [/api/v1/custom_emojis](https://docs.joinmastodon.org/methods/custom_emojis/#get) +- [ ] GET [/api/v1/custom_emojis](https://docs.joinmastodon.org/methods/custom_emojis/#get) #### directory -- [] GET [/api/v1/directory](https://docs.joinmastodon.org/methods/directory/#get) +- [ ] GET [/api/v1/directory](https://docs.joinmastodon.org/methods/directory/#get) #### domain_blocks -- [] GET [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#get) -- [] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#block) -- [] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#unblock) +- [ ] GET [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#get) +- [ ] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#block) +- [ ] POST [/api/v1/domain_blocks](https://docs.joinmastodon.org/methods/domain_blocks/#unblock) #### emails -- [] POST [/api/v1/emails/confirmations](https://docs.joinmastodon.org/methods/emails/#confirmation) +- [ ] POST [/api/v1/emails/confirmations](https://docs.joinmastodon.org/methods/emails/#confirmation) ### Features From dae0add844b4e36fe5046346f12a200560f67e13 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 26 Jun 2025 07:49:01 +0200 Subject: [PATCH 121/144] Update README.md --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/README.md b/README.md index ba66884..a796b2e 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,80 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin - [ ] POST [/api/v1/emails/confirmations](https://docs.joinmastodon.org/methods/emails/#confirmation) +#### endorsements + +- [ ] GET [/api/v1/endorsements](https://docs.joinmastodon.org/methods/endorsements/#get) + +#### favourites + +- [ ] GET [/api/v1/favourites](https://docs.joinmastodon.org/methods/favourites/#get) + +#### featured_tags + +- [ ] GET [/api/v1/featured_tags](https://docs.joinmastodon.org/methods/featured_tags/#get) +- [ ] POST [/api/v1/featured_tags](https://docs.joinmastodon.org/methods/featured_tags/#feature) +- [ ] GET [/api/v1/featured_tags/:id](https://docs.joinmastodon.org/methods/featured_tags/#unfeature) +- [ ] GET [/api/v1/featured_tags/suggestions](https://docs.joinmastodon.org/methods/featured_tags/#suggestions) + +#### filters + +- [ ] GET [/api/v2/filters](https://docs.joinmastodon.org/methods/filters/#get) +- [ ] GET [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#get-one) +- [ ] POST [/api/v2/filters](https://docs.joinmastodon.org/methods/filters/#create) +- [ ] PUT [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#update) +- [ ] DELETE [/api/v2/filters/:id](https://docs.joinmastodon.org/methods/filters/#delete) +- [ ] GET [/api/v2/filters/:filter_id/keywords](https://docs.joinmastodon.org/methods/filters/#keywords-get) +- [ ] POST [/api/v2/filters/:filter_id/keywords](https://docs.joinmastodon.org/methods/filters/#keywords-create) +- [ ] GET [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-get-one) +- [ ] PUT [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-update) +- [ ] DELETE [/api/v2/filters/keywords/:id](https://docs.joinmastodon.org/methods/filters/#keywords-delete) +- [ ] GET [/api/v2/filters/:filter_id/notes](https://docs.joinmastodon.org/methods/filters/#statuses-get) +- [ ] POST [/api/v2/filters/:filter_id/notes](https://docs.joinmastodon.org/methods/filters/#statuses-add) +- [ ] GET [/api/v2/filters/notes/:id](https://docs.joinmastodon.org/methods/filters/#statuses-get-one) +- [ ] DELETE [/api/v2/filters/notes/:id](https://docs.joinmastodon.org/methods/filters/#statuses-remove) + +#### follow_requests + +- [ ] GET [/api/v1/follow_requests](https://docs.joinmastodon.org/methods/follow_requests/#get) +- [ ] POST [/api/v1/follow_requests/:account_id/authorize](https://docs.joinmastodon.org/methods/follow_requests/#accept) +- [ ] POST [/api/v1/follow_requests/:account_id/reject](https://docs.joinmastodon.org/methods/follow_requests/#reject) + +#### followed_tags + +- [ ] GET [/api/v1/followed_tags](https://docs.joinmastodon.org/methods/followed_tags/#get) + +#### grouped_notifications + +- [ ] GET [/api/v2/notifications](https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped) +- [ ] GET [/api/v2/notifications/:group_key](https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group) +- [ ] POST [/api/v2/notifications/:group_key/dismiss](https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group) +- [ ] GET [/api/v2/notifications/:group_key/accounts](https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts) +- [ ] GET [/api/v2/notifications/unread_count](https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count) + +#### instance + +- [ ] GET [/api/v2/instance](https://docs.joinmastodon.org/methods/instance/#v2) +- [ ] GET [/api/v2/instance/peers](https://docs.joinmastodon.org/methods/instance/#peers) +- [ ] GET [/api/v2/instance/activity](https://docs.joinmastodon.org/methods/instance/#activity) +- [ ] GET [/api/v2/instance/rules](https://docs.joinmastodon.org/methods/instance/#rules) +- [ ] GET [/api/v2/instance/domain_blocks](https://docs.joinmastodon.org/methods/instance/#domain_blocks) +- [ ] GET [/api/v2/instance/extended_description](https://docs.joinmastodon.org/methods/instance/#extended_description) +- [ ] GET [/api/v2/instance/privacy_policy](https://docs.joinmastodon.org/methods/instance/#privacy_policy) +- [ ] GET [/api/v2/instance/terms_of_service](https://docs.joinmastodon.org/methods/instance/#terms_of_service) +- [ ] GET [/api/v2/instance/terms_of_service/:date](https://docs.joinmastodon.org/methods/instance/#terms_of_service_date) +- [ ] GET [/api/v2/instance/translation_languages](https://docs.joinmastodon.org/methods/instance/#translation_languages) + +#### lists + +- [ ] GET [/api/v1/lists](https://docs.joinmastodon.org/methods/lists/#get) +- [ ] GET [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#get-one) +- [ ] POST [/api/v1/lists](https://docs.joinmastodon.org/methods/lists/#create) +- [ ] PUT [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#update) +- [ ] DELETE [/api/v1/lists/:id](https://docs.joinmastodon.org/methods/lists/#delete) +- [ ] GET [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts) +- [ ] POST [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-add) +- [ ] DELETE [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-remove) + ### Features - [ ] Lightweight web interface From a1b1c4842d20bda83c6a621f0591b86351bf6963 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 26 Jun 2025 09:44:37 +0200 Subject: [PATCH 122/144] Update README.md --- README.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/README.md b/README.md index a796b2e..d326e93 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,104 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin - [ ] POST [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-add) - [ ] DELETE [/api/v1/lists/:id/accounts](https://docs.joinmastodon.org/methods/lists/#accounts-remove) +#### markers + +- [ ] GET [/api/v1/markers](https://docs.joinmastodon.org/methods/markers/#get) +- [ ] POST [/api/v1/markers](https://docs.joinmastodon.org/methods/lists/#create) + +#### media + +- [ ] POST [/api/v2/media](https://docs.joinmastodon.org/methods/media/#v2) +- [ ] GET [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#get) +- [ ] DELETE [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#delete) +- [ ] PUT [/api/v1/media/:id](https://docs.joinmastodon.org/methods/media/#update) + +#### mutes + +- [ ] GET [/api/v1/mutes](https://docs.joinmastodon.org/methods/mutes/#get) + +#### notifications + +- [ ] GET [/api/v1/notifications](https://docs.joinmastodon.org/methods/notifications/#get) +- [ ] GET [/api/v1/notifications/:id](https://docs.joinmastodon.org/methods/notifications/#get-one) +- [ ] POST [/api/v1/notifications/clear](https://docs.joinmastodon.org/methods/notifications/#clear) +- [ ] POST [/api/v1/notifications/:id/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss) +- [ ] GET [/api/v1/notifications/unread_count](https://docs.joinmastodon.org/methods/notifications/#unread-count) +- [ ] GET [/api/v2/notifications/policy](https://docs.joinmastodon.org/methods/notifications/#policy) +- [ ] PATCH [/api/v2/notifications/policy](https://docs.joinmastodon.org/methods/notifications/#get-policy) +- [ ] GET [/api/v1/notifications/requests](https://docs.joinmastodon.org/methods/notifications/#get-requests) +- [ ] GET [/api/v1/notifications/requests/:id](https://docs.joinmastodon.org/methods/notifications/#get-one-request) +- [ ] POST [/api/v1/notifications/requests/:id/accept](https://docs.joinmastodon.org/methods/notifications/#accept-request) +- [ ] POST [/api/v1/notifications/requests/:id/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss-request) +- [ ] POST [/api/v1/notifications/requests/accept](https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests) +- [ ] POST [/api/v1/notifications/requests/dismiss](https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests) +- [ ] GET [/api/v1/notifications/requests/merged](https://docs.joinmastodon.org/methods/notifications/#requests-merged) + +#### oauth + +- [ ] GET [/oauth/authorize](https://docs.joinmastodon.org/methods/oauth/#authorize) +- [ ] POST [/oauth/token](https://docs.joinmastodon.org/methods/oauth/#token) +- [ ] POST [/oauth/revoke](https://docs.joinmastodon.org/methods/oauth/#revoke) +- [ ] GET [/oauth/userinfo](https://docs.joinmastodon.org/methods/oauth/#userinfo) +- [ ] GET [/.well-known/oauth-authorization-server](https://docs.joinmastodon.org/methods/oauth/#authorization-server-metadata) + +#### oembed + +- [ ] GET [/api/oembed](https://docs.joinmastodon.org/methods/oembed/#get) + +#### polls + +- [ ] GET [/api/v1/polls/:id](https://docs.joinmastodon.org/methods/polls/#get) +- [ ] POST [/api/v1/polls/:id/votes](https://docs.joinmastodon.org/methods/polls/#vote) + +#### preferences + +- [ ] GET [/api/v1/preferences](https://docs.joinmastodon.org/methods/preferences/#get) + +#### profile + +- [ ] DELETE [/api/v1/profile/avatar](https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar) +- [ ] DELETE [/api/v1/profile/header](https://docs.joinmastodon.org/methods/profile/#delete-profile-header) + +#### push + +- [ ] POST [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#create) +- [ ] GET [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#get) +- [ ] PUT [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#update) +- [ ] DELETE [/api/v1/push/subscription](https://docs.joinmastodon.org/methods/push/#delete) + +#### reports + +- [ ] POST [/api/v1/reports](https://docs.joinmastodon.org/methods/reports/#post) + +#### search + +- [ ] GET [/api/v2/search](https://docs.joinmastodon.org/methods/search/#v2) + +#### notes + +- [ ] POST [/api/v1/notes](https://docs.joinmastodon.org/methods/statuses/#create) +- [ ] GET [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#get) +- [ ] GET [/api/v1/notes](https://docs.joinmastodon.org/methods/statuses/#index) +- [ ] DELETE [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#delete) +- [ ] GET [/api/v1/notes/:id/context](https://docs.joinmastodon.org/methods/statuses/#context) +- [ ] POST [/api/v1/notes/:id/translate](https://docs.joinmastodon.org/methods/statuses/#translate) +- [ ] GET [/api/v1/notes/:id/reblogged_by](https://docs.joinmastodon.org/methods/statuses/#reblogged_by) +- [ ] GET [/api/v1/notes/:id/favourited_by](https://docs.joinmastodon.org/methods/statuses/#favourited_by) +- [ ] POST [/api/v1/notes/:id/favourite](https://docs.joinmastodon.org/methods/statuses/#favourite) +- [ ] POST [/api/v1/notes/:id/unfavourite](https://docs.joinmastodon.org/methods/statuses/#unfavourite) +- [ ] POST [/api/v1/notes/:id/reblog](https://docs.joinmastodon.org/methods/statuses/#boost) +- [ ] POST [/api/v1/notes/:id/unreblog](https://docs.joinmastodon.org/methods/statuses/#unreblog) +- [ ] POST [/api/v1/notes/:id/bookmark](https://docs.joinmastodon.org/methods/statuses/#bookmark) +- [ ] POST [/api/v1/notes/:id/unbookmark](https://docs.joinmastodon.org/methods/statuses/#unbookmark) +- [ ] POST [/api/v1/notes/:id/mute](https://docs.joinmastodon.org/methods/statuses/#mute) +- [ ] POST [/api/v1/notes/:id/unmute](https://docs.joinmastodon.org/methods/statuses/#unmute) +- [ ] POST [/api/v1/notes/:id/pin](https://docs.joinmastodon.org/methods/statuses/#pin) +- [ ] POST [/api/v1/notes/:id/unpin](https://docs.joinmastodon.org/methods/statuses/#unpin) +- [ ] PUT [/api/v1/notes/:id](https://docs.joinmastodon.org/methods/statuses/#edit) +- [ ] GET [/api/v1/notes/:id/history](https://docs.joinmastodon.org/methods/statuses/#history) +- [ ] GET [/api/v1/notes/:id/source](https://docs.joinmastodon.org/methods/statuses/#source) + ### Features - [ ] Lightweight web interface From fe03ce0d7fca9220e5abfaa5c84fd42478a34115 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 26 Jun 2025 09:59:27 +0200 Subject: [PATCH 123/144] Update README.md --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index d326e93..87f7f5b 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,47 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin - [ ] GET [/api/v1/notes/:id/history](https://docs.joinmastodon.org/methods/statuses/#history) - [ ] GET [/api/v1/notes/:id/source](https://docs.joinmastodon.org/methods/statuses/#source) +#### streaming + +- [ ] GET [/api/v1/streaming/health](https://docs.joinmastodon.org/methods/streaming/#health) +- [ ] GET [/api/v1/streaming/user](https://docs.joinmastodon.org/methods/streaming/#user) +- [ ] GET [/api/v1/streaming/user/notification](https://docs.joinmastodon.org/methods/streaming/#notification) +- [ ] GET [/api/v1/streaming/public](https://docs.joinmastodon.org/methods/streaming/#public) +- [ ] GET [/api/v1/streaming/public/local](https://docs.joinmastodon.org/methods/streaming/#public-local) +- [ ] GET [/api/v1/streaming/public/remote](https://docs.joinmastodon.org/methods/streaming/#public-remote) +- [ ] GET [/api/v1/streaming/hashtag](https://docs.joinmastodon.org/methods/streaming/#hashtag) +- [ ] GET [/api/v1/streaming/hashtag/local](https://docs.joinmastodon.org/methods/streaming/#hashtag-local) +- [ ] GET [/api/v1/streaming/list](https://docs.joinmastodon.org/methods/streaming/#list) +- [ ] GET [/api/v1/streaming/direct](https://docs.joinmastodon.org/methods/streaming/#direct) +- [ ] WSS [/api/v1/streaming](https://docs.joinmastodon.org/methods/streaming/#websocket) + +#### suggestions + +- [ ] GET [/api/v2/suggestions](https://docs.joinmastodon.org/methods/suggestions/#v2) +- [ ] DELETE [/api/v1/suggestions/:account_id](https://docs.joinmastodon.org/methods/suggestions/#remove) + +#### tags + +- [ ] GET [/api/v1/tags/:name](https://docs.joinmastodon.org/methods/tags/#get) +- [ ] POST [/api/v1/tags/:name/follow](https://docs.joinmastodon.org/methods/tags/#follow) +- [ ] POST [/api/v1/tags/:name/unfollow](https://docs.joinmastodon.org/methods/tags/#unfollow) +- [ ] POST [/api/v1/tags/:id/feature](https://docs.joinmastodon.org/methods/tags/#feature) +- [ ] POST [/api/v1/tags/:id/unfeature](https://docs.joinmastodon.org/methods/tags/#unfeature) + +#### timelines + +- [ ] GET [/api/v1/timelines/public](https://docs.joinmastodon.org/methods/timelines/#public) +- [ ] GET [/api/v1/timelines/tag/:hashtag](https://docs.joinmastodon.org/methods/timelines/#tag) +- [ ] GET [/api/v1/timelines/home](https://docs.joinmastodon.org/methods/timelines/#home) +- [ ] GET [/api/v1/timelines/link?url=:url](https://docs.joinmastodon.org/methods/timelines/#link) +- [ ] GET [/api/v1/timelines/list/:list_id](https://docs.joinmastodon.org/methods/timelines/#list) + +#### trends + +- [ ] GET [/api/v1/trends/tags](https://docs.joinmastodon.org/methods/trends/#tags) +- [ ] GET [/api/v1/trends/notes](https://docs.joinmastodon.org/methods/trends/#statuses) +- [ ] GET [/api/v1/trends/links](https://docs.joinmastodon.org/methods/trends/#links) + ### Features - [ ] Lightweight web interface From 7a4207c58a32e19d9237bec33d3a652d9756681f Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Thu, 26 Jun 2025 10:00:46 +0200 Subject: [PATCH 124/144] Update README.md --- README.md | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/README.md b/README.md index 87f7f5b..8d4ba93 100644 --- a/README.md +++ b/README.md @@ -304,53 +304,6 @@ An agnostic social network following the KISS and UNIX philosophy, the main prin - [ ] Restricted direct messages - [ ] Direct messages tab -### Server configuration - -* Disk space limit per user -* Limit on posts (count/time) - -### User settings - -#### Profile - -* Avatar -* Banner -* Name -* Bio -* Location -* Birthday -* Links -* Follow requests approval -* Toggle reacts under own posts -* Toggle view of reacts under posts -* Profile migration -* Delete account - -===== Disk Usage: 100 MB (20%) ===== - -#### Security - -* Change password -* Token -* Enable/Disable email login notifications -* Sessions - -| IP | Datetime | Action | -| ----------------|---------------------|--------| -| 127.0.0.1 | 2025-01-01 00:00:00 | revoke | -| 127.127.127.127 | 2025-02-02 00:00:00 | revoke | - -#### Filters - -* Placeholder with rules - -``` -filter keyword #tag user@example.com example.com -``` - -* Show replies of all followed users -* Show replies of this followed users - ## Contributing ### Patches via Email From 2cfc459cd0fb04d233c6268ab45ef972120852ab Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 27 Jun 2025 16:35:35 +0200 Subject: [PATCH 125/144] Update --- .../{httpsignature.ex => http_signature.ex} | 43 +++ lib/nulla/{keygen.ex => key_gen.ex} | 0 lib/nulla/models/actor.ex | 21 ++ lib/nulla/models/relation.ex | 17 ++ lib/nulla_web/controllers/inbox_controller.ex | 251 ++++++++++++------ 5 files changed, 246 insertions(+), 86 deletions(-) rename lib/nulla/{httpsignature.ex => http_signature.ex} (61%) rename lib/nulla/{keygen.ex => key_gen.ex} (100%) diff --git a/lib/nulla/httpsignature.ex b/lib/nulla/http_signature.ex similarity index 61% rename from lib/nulla/httpsignature.ex rename to lib/nulla/http_signature.ex index dad2d7f..ccfe0cc 100644 --- a/lib/nulla/httpsignature.ex +++ b/lib/nulla/http_signature.ex @@ -1,5 +1,48 @@ defmodule Nulla.HTTPSignature do import Plug.Conn + alias Nulla.Models.User + + def make_header(body, inbox_url, actor) do + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + uri = URI.parse(inbox_url) + + signature_string = """ + (request-target): post #{uri.path} + host: #{uri.host} + date: #{date} + digest: #{digest} + """ + + user = User.get_user(id: actor.id) + + private_key = + case :public_key.pem_decode(user.privateKeyPem) do + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{actor.publicKey["id"]}", + algorithm="rsa-sha256", + headers="(request-target) host date digest", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"Digest", digest}, + {"Signature", signature_header} + ] + end def verify(conn, public_key_pem) do with [sig_header] <- get_req_header(conn, "signature"), diff --git a/lib/nulla/keygen.ex b/lib/nulla/key_gen.ex similarity index 100% rename from lib/nulla/keygen.ex rename to lib/nulla/key_gen.ex diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index a8b8072..5c78142 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -134,4 +134,25 @@ defmodule Nulla.Models.Actor do def get_actor(by) when is_map(by) or is_list(by) do Repo.get_by(__MODULE__, by) end + + def get_or_create_actor(actor_json) do + ap_id = actor_json["id"] + + case __MODULE__.get_actor(ap_id: ap_id) do + nil -> + params = + actor_json + |> Map.put("ap_id", ap_id) + |> Map.delete("id") + |> Map.put("domain", URI.parse(ap_id).host) + + case __MODULE__.create_actor(params) do + {:ok, actor} -> {:ok, actor} + {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} + end + + actor -> + {:ok, actor} + end + end end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 609ff0d..506b283 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -65,6 +65,23 @@ defmodule Nulla.Models.Relation do Repo.get_by(__MODULE__, by) end + def get_or_create_relation(local_actor_id, remote_actor_id) do + case __MODULE__.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do + nil -> + case __MODULE__.create_relation(%{ + followed_by: true, + local_actor_id: local_actor_id, + remote_actor_id: remote_actor_id + }) do + {:ok, relation} -> {:ok, relation} + {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} + end + + relation -> + {:ok, relation} + end + end + def count_following(local_actor_id) do __MODULE__ |> where([r], r.local_actor_id == ^local_actor_id and r.following == true) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index a787c55..168503e 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -4,11 +4,82 @@ defmodule NullaWeb.InboxController do alias Nulla.Snowflake alias Nulla.HTTPSignature alias Nulla.Utils - alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Activity + def inbox(conn, %{ + "id" => _create_id, + "type" => "Create", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _read_id, + "type" => "Read", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _update_id, + "type" => "Update", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _delete_id, + "type" => "Delete", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _add_id, + "type" => "Add", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _view_id, + "type" => "View", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _move_id, + "type" => "Move", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + + def inbox(conn, %{ + "id" => _undo_id, + "type" => "Undo", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end + def inbox(conn, %{ "id" => follow_id, "type" => "Follow", @@ -20,7 +91,7 @@ defmodule NullaWeb.InboxController do with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), :ok <- HTTPSignature.verify(conn, remote_actor_json["publicKey"]["publicKeyPem"]), - {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), + {:ok, remote_actor} <- Actor.get_or_create_actor(remote_actor_json), {:ok, follow_activity} <- Activity.create_activity(%{ ap_id: follow_id, @@ -36,8 +107,26 @@ defmodule NullaWeb.InboxController do actor: local_actor.ap_id, object: Jason.encode!(follow_activity) }), - {:ok, _relation} <- get_or_create_relation(local_actor.id, remote_actor.id), - :ok <- deliver_accept(accept_activity, remote_actor_json["inbox"], local_actor) do + {:ok, _relation} <- Relation.get_or_create_relation(local_actor.id, remote_actor.id) do + activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} + body = Jason.encode!(ActivityPub.activity(activity)) + headers = HTTPSignature.make_header(body, remote_actor_json["inbox"], local_actor) + request = Finch.build(:post, remote_actor_json["inbox"], headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Accept delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver Accept") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Finch delivery failed") + {:error, reason} + end + send_resp(conn, 200, "") else error -> @@ -46,98 +135,88 @@ defmodule NullaWeb.InboxController do end end - defp get_or_create_actor(remote_actor_json) do - ap_id = remote_actor_json["id"] - - case Actor.get_actor(ap_id: ap_id) do - nil -> - params = - remote_actor_json - |> Map.put("ap_id", ap_id) - |> Map.delete("id") - |> Map.put("domain", URI.parse(ap_id).host) - - case Actor.create_actor(params) do - {:ok, actor} -> {:ok, actor} - {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} - end - - actor -> - {:ok, actor} - end + def inbox(conn, %{ + "id" => _accept_id, + "type" => "Accept", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") end - defp get_or_create_relation(local_actor_id, remote_actor_id) do - case Relation.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do - nil -> - Relation.create_relation(%{ - followed_by: true, - local_actor_id: local_actor_id, - remote_actor_id: remote_actor_id - }) - - relation -> - {:ok, relation} - end + def inbox(conn, %{ + "id" => _reject_id, + "type" => "Reject", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") end - defp deliver_accept(accept_activity, inbox_url, local_actor) do - accept_activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} - body = Jason.encode!(ActivityPub.activity(accept_activity)) - digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) - date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") - uri = URI.parse(inbox_url) + def inbox(conn, %{ + "id" => _block_id, + "type" => "Block", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - signature_string = """ - (request-target): post #{uri.path} - host: #{uri.host} - date: #{date} - digest: #{digest} - """ + def inbox(conn, %{ + "id" => _join_id, + "type" => "Join", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - user = User.get_user(id: local_actor.id) + def inbox(conn, %{ + "id" => _leave_id, + "type" => "Leave", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - private_key = - case :public_key.pem_decode(user.privateKeyPem) do - [entry] -> :public_key.pem_entry_decode(entry) - _ -> raise "Invalid PEM format" - end + def inbox(conn, %{ + "id" => _like_id, + "type" => "Like", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - signature = - :public_key.sign(signature_string, :sha256, private_key) - |> Base.encode64() + def inbox(conn, %{ + "id" => _dislike_id, + "type" => "Dislike", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - signature_header = - """ - keyId="#{local_actor.publicKey["id"]}", - algorithm="rsa-sha256", - headers="(request-target) host date digest", - signature="#{signature}" - """ - |> String.replace("\n", "") - |> String.trim() + def inbox(conn, %{ + "id" => _announce_id, + "type" => "Announce", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - headers = [ - {"Content-Type", "application/activity+json"}, - {"Date", date}, - {"Digest", digest}, - {"Signature", signature_header} - ] + def inbox(conn, %{ + "id" => _question_id, + "type" => "Question", + "actor" => _actor_uri, + "object" => _target_uri + }) do + send_resp(conn, 200, "") + end - request = Finch.build(:post, inbox_url, headers, body) - - case Finch.request(request, Nulla.Finch) do - {:ok, %Finch.Response{status: code}} when code in 200..299 -> - IO.puts("Accept delivered successfully") - :ok - - {:ok, %Finch.Response{status: code, body: resp}} -> - IO.inspect({:error, code, resp}, label: "Failed to deliver Accept") - {:error, {:http_error, code}} - - {:error, reason} -> - IO.inspect(reason, label: "Finch delivery failed") - {:error, reason} - end + def inbox(conn, _params) do + send_resp(conn, 400, "") end end From 88b8b379e74e1654b3527de7da619d60b4f08809 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 27 Jun 2025 16:50:24 +0200 Subject: [PATCH 126/144] Update routes --- lib/nulla_web/controllers/actor_controller.ex | 2 +- lib/nulla_web/router.ex | 38 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index c4b8479..bfea76d 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do |> json(%{error: "Not Found"}) %Actor{} = actor -> - if accept in ["application/activity+json", "application/ld+json"] do + if accept in ["application/activity+json"] do conn |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 53d3633..31448b2 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -2,7 +2,7 @@ defmodule NullaWeb.Router do use NullaWeb, :router pipeline :browser do - plug :accepts, ["html", "json", "activity+json", "ld+json"] + plug :accepts, ["html", "json", "activity+json"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} @@ -14,6 +14,10 @@ defmodule NullaWeb.Router do plug :accepts, ["json"] end + pipeline :activitypub do + plug :accepts, ["activity+json"] + end + scope "/", NullaWeb do pipe_through :browser @@ -23,7 +27,6 @@ defmodule NullaWeb.Router do get "/.well-known/webfinger", WebfingerController, :index get "/.well-known/nodeinfo", NodeinfoController, :index get "/nodeinfo/2.0", NodeinfoController, :show - post "/inbox", InboxController, :inbox scope "/auth" do post "/", AuthController, :sign_up @@ -33,15 +36,6 @@ defmodule NullaWeb.Router do get "/sign_in", PageController, :sign_in end - scope "/users/:username" do - get "/", ActorController, :show - get "/following", FollowController, :following - get "/followers", FollowController, :followers - post "/inbox", InboxController, :inbox - get "/outbox", OutboxController, :outbox - get "/notes/:id", NoteController, :show - end - scope "/@:username" do get "/", ActorController, :show get "/following", FollowController, :following @@ -52,10 +46,24 @@ defmodule NullaWeb.Router do end end - # Other scopes may use custom stacks. - # scope "/api", NullaWeb do - # pipe_through :api - # end + scope "/api", NullaWeb do + pipe_through :api + end + + scope "/", NullaWeb do + pipe_through :activitypub + + post "/inbox", InboxController, :inbox + + scope "/users/:username" do + get "/", ActorController, :show + get "/following", FollowController, :following + get "/followers", FollowController, :followers + post "/inbox", InboxController, :inbox + get "/outbox", OutboxController, :outbox + get "/notes/:id", NoteController, :show + end + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:nulla, :dev_routes) do From 406cd1798cdd8fd4adb735d1a44b4f555b6fede5 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 27 Jun 2025 17:11:20 +0200 Subject: [PATCH 127/144] Rename make_header to make_headers --- lib/nulla/http_signature.ex | 2 +- lib/nulla_web/controllers/inbox_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex index ccfe0cc..e32e5b2 100644 --- a/lib/nulla/http_signature.ex +++ b/lib/nulla/http_signature.ex @@ -2,7 +2,7 @@ defmodule Nulla.HTTPSignature do import Plug.Conn alias Nulla.Models.User - def make_header(body, inbox_url, actor) do + def make_headers(body, inbox_url, actor) do digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") uri = URI.parse(inbox_url) diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 168503e..0323657 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -110,7 +110,7 @@ defmodule NullaWeb.InboxController do {:ok, _relation} <- Relation.get_or_create_relation(local_actor.id, remote_actor.id) do activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} body = Jason.encode!(ActivityPub.activity(activity)) - headers = HTTPSignature.make_header(body, remote_actor_json["inbox"], local_actor) + headers = HTTPSignature.make_headers(body, remote_actor_json["inbox"], local_actor) request = Finch.build(:post, remote_actor_json["inbox"], headers, body) case Finch.request(request, Nulla.Finch) do From c30541830ffd3bd202d01812e25dc52faecdabda Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sat, 28 Jun 2025 18:56:37 +0200 Subject: [PATCH 128/144] Update tests --- lib/nulla/models/note.ex | 16 ++- lib/nulla_web/controllers/actor_controller.ex | 4 +- lib/nulla_web/controllers/note_controller.ex | 6 +- .../controllers/actor_controller_test.exs | 34 +------ .../controllers/follow_controller_test.exs | 43 +------- .../controllers/nodeinfo_controller_test.exs | 49 +--------- .../controllers/note_controller_test.exs | 97 +++---------------- .../controllers/outbox_controller_test.exs | 46 +-------- .../controllers/webfinger_controller_test.exs | 30 +----- test/support/fixtures/data.ex | 93 ++++++++++++++++++ 10 files changed, 134 insertions(+), 284 deletions(-) create mode 100644 test/support/fixtures/data.ex diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 6d3cf8b..a109559 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -36,13 +36,27 @@ defmodule Nulla.Models.Note do def create_note(attrs) when is_map(attrs) do id = Map.get(attrs, :id, Snowflake.next_id()) + url = + case Map.get(attrs, :url) do + nil -> + actor_url = Map.get(attrs, :actor_url) + + "#{actor_url}/#{id}" + + _ -> + Map.get(attrs, :url) + end + %__MODULE__{} |> changeset(attrs) |> put_change(:id, id) + |> put_change(:url, url) |> Repo.insert() end - def get_note(id), do: Repo.get(__MODULE__, id) + def get_note(by) when is_map(by) or is_list(by) do + Repo.get_by(__MODULE__, by) + end def get_latest_notes(actor_id, limit \\ 20) do from(n in __MODULE__, diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index bfea76d..3771961 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -6,7 +6,7 @@ defmodule NullaWeb.ActorController do alias Nulla.Models.InstanceSettings def show(conn, %{"username" => username}) do - accept = List.first(get_req_header(conn, "accept")) + format = Phoenix.Controller.get_format(conn) instance_settings = InstanceSettings.get_instance_settings!() domain = instance_settings.domain @@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do |> json(%{error: "Not Found"}) %Actor{} = actor -> - if accept in ["application/activity+json"] do + if format == "activity+json" do conn |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex index bafa1c0..f76a56a 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/note_controller.ex @@ -7,7 +7,7 @@ defmodule NullaWeb.NoteController do def show(conn, %{"username" => username, "id" => id}) do case Integer.parse(id) do {int_id, ""} -> - note = Note.get_note(int_id) |> Repo.preload([:actor, :media_attachments]) + note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments]) cond do is_nil(note) -> @@ -23,9 +23,9 @@ defmodule NullaWeb.NoteController do |> halt() true -> - accept = List.first(get_req_header(conn, "accept")) + format = Phoenix.Controller.get_format(conn) - if accept in ["application/activity+json", "application/ld+json"] do + if format == "activity+json" do conn |> put_resp_content_type("application/activity+json") |> json(ActivityPub.note(note)) diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/actor_controller_test.exs index 2866031..e2af38c 100644 --- a/test/nulla_web/controllers/actor_controller_test.exs +++ b/test/nulla_web/controllers/actor_controller_test.exs @@ -1,36 +1,8 @@ defmodule NullaWeb.ActorControllerTest do use NullaWeb.ConnCase - alias Nulla.Models.Actor setup do - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: "PUBLIC KEY" - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - + Nulla.Fixtures.Data.create() :ok end @@ -72,7 +44,7 @@ defmodule NullaWeb.ActorControllerTest do end test "renders HTML if Accept header is regular", %{conn: conn} do - conn = get(conn, ~p"/users/test") + conn = get(conn, ~p"/@test") assert html_response(conn, 200) =~ "test" assert html_response(conn, 200) =~ "Test" @@ -80,7 +52,7 @@ defmodule NullaWeb.ActorControllerTest do end test "returns 404 if actor not found", %{conn: conn} do - conn = get(conn, ~p"/users/nonexistent") + conn = get(conn, ~p"/@nonexistent") assert json_response(conn, 404)["error"] == "Not Found" end diff --git a/test/nulla_web/controllers/follow_controller_test.exs b/test/nulla_web/controllers/follow_controller_test.exs index 900c112..b285e7f 100644 --- a/test/nulla_web/controllers/follow_controller_test.exs +++ b/test/nulla_web/controllers/follow_controller_test.exs @@ -1,49 +1,8 @@ defmodule NullaWeb.FollowControllerTest do use NullaWeb.ConnCase - alias Nulla.KeyGen - alias Nulla.Models.User - alias Nulla.Models.Actor setup do - {publicKeyPem, privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - User.create_user(%{ - id: actor.id, - email: "test@localhost", - password: "password", - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) - + Nulla.Fixtures.Data.create() :ok end diff --git a/test/nulla_web/controllers/nodeinfo_controller_test.exs b/test/nulla_web/controllers/nodeinfo_controller_test.exs index d52add7..1fd570f 100644 --- a/test/nulla_web/controllers/nodeinfo_controller_test.exs +++ b/test/nulla_web/controllers/nodeinfo_controller_test.exs @@ -1,49 +1,8 @@ defmodule NullaWeb.NodeinfoControllerTest do use NullaWeb.ConnCase - alias Nulla.KeyGen - alias Nulla.Models.User - alias Nulla.Models.Actor setup do - {publicKeyPem, privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - User.create_user(%{ - id: actor.id, - email: "test@localhost", - password: "password", - privateKeyPem: privateKeyPem, - last_active_at: DateTime.utc_now() - }) - + Nulla.Fixtures.Data.create() :ok end @@ -83,9 +42,9 @@ defmodule NullaWeb.NodeinfoControllerTest do assert is_list(response["services"]["inbound"]) assert is_map(response["usage"]) assert is_map(response["usage"]["users"]) - assert response["usage"]["users"]["total"] == 1 - assert response["usage"]["users"]["activeMonth"] == 1 - assert response["usage"]["users"]["activeHalfyear"] == 1 + assert response["usage"]["users"]["total"] > 0 + assert response["usage"]["users"]["activeMonth"] > 0 + assert response["usage"]["users"]["activeHalfyear"] > 0 assert is_boolean(response["openRegistrations"]) assert is_map(response["metadata"]) assert is_binary(response["metadata"]["nodeName"]) diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs index a4bcf49..d094f83 100644 --- a/test/nulla_web/controllers/note_controller_test.exs +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -1,53 +1,17 @@ defmodule NullaWeb.NoteControllerTest do use NullaWeb.ConnCase - alias Nulla.KeyGen - alias Nulla.Snowflake alias Nulla.Models.Actor alias Nulla.Models.Note + setup do + Nulla.Fixtures.Data.create() + :ok + end + describe "GET /notes/id" do test "returns ActivityPub JSON with note", %{conn: conn} do - {publicKeyPem, _privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - note_id = Snowflake.next_id() - - {:ok, note} = - Note.create_note(%{ - id: note_id, - url: "#{actor.url}/#{note_id}", - content: "Hello World from Nulla!", - language: "en", - actor_id: actor.id - }) + actor = Actor.get_actor(preferredUsername: "test") + note = Note.get_note(actor_id: actor.id) conn = conn @@ -73,55 +37,16 @@ defmodule NullaWeb.NoteControllerTest do end test "renders HTML if Accept header is regular", %{conn: conn} do - {publicKeyPem, _privateKeyPem} = KeyGen.gen() + actor = Actor.get_actor(preferredUsername: "test") + note = Note.get_note(actor_id: actor.id) - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - note_id = Snowflake.next_id() - - {:ok, note} = - Note.create_note(%{ - id: note_id, - url: "#{actor.url}/#{note_id}", - content: "Hello World from Nulla!", - language: "en", - actor_id: actor.id - }) - - conn = get(conn, ~p"/users/test/notes/#{note.id}") + conn = get(conn, ~p"/@test/#{note.id}") assert html_response(conn, 200) =~ note.content end test "returns 404 if note not found", %{conn: conn} do - conn = get(conn, ~p"/users/test/notes/nonexistent") + conn = get(conn, ~p"/@test/nonexistent") assert json_response(conn, 404)["error"] == "Not Found" end diff --git a/test/nulla_web/controllers/outbox_controller_test.exs b/test/nulla_web/controllers/outbox_controller_test.exs index 18ad04c..e8d0bd2 100644 --- a/test/nulla_web/controllers/outbox_controller_test.exs +++ b/test/nulla_web/controllers/outbox_controller_test.exs @@ -1,52 +1,8 @@ defmodule NullaWeb.OutboxControllerTest do use NullaWeb.ConnCase - alias Nulla.KeyGen - alias Nulla.Snowflake - alias Nulla.Models.Actor - alias Nulla.Models.Note setup do - {publicKeyPem, _privateKeyPem} = KeyGen.gen() - - {:ok, actor} = - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: publicKeyPem - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - - note_id = Snowflake.next_id() - - {:ok, _note} = - Note.create_note(%{ - url: "#{actor.url}/#{note_id}", - content: "Hello World from Nulla!", - language: "en", - actor_id: actor.id - }) - + Nulla.Fixtures.Data.create() :ok end diff --git a/test/nulla_web/controllers/webfinger_controller_test.exs b/test/nulla_web/controllers/webfinger_controller_test.exs index 041ec5a..b0f70c7 100644 --- a/test/nulla_web/controllers/webfinger_controller_test.exs +++ b/test/nulla_web/controllers/webfinger_controller_test.exs @@ -1,36 +1,8 @@ defmodule NullaWeb.WebfingerControllerTest do use NullaWeb.ConnCase - alias Nulla.Models.Actor setup do - Actor.create_actor(%{ - domain: "localhost", - ap_id: "http://localhost/users/test", - type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", - preferredUsername: "test", - name: "Test", - summary: "Test User", - url: "http://localhost/@test", - manuallyApprovesFollowers: false, - discoverable: true, - indexable: true, - published: DateTime.utc_now(), - memorial: false, - publicKey: - Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", - publicKeyPem: "PUBLIC KEY" - ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") - }) - + Nulla.Fixtures.Data.create() :ok end diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex new file mode 100644 index 0000000..571888f --- /dev/null +++ b/test/support/fixtures/data.ex @@ -0,0 +1,93 @@ +defmodule Nulla.Fixtures.Data do + alias Nulla.KeyGen + alias Nulla.Models.User + alias Nulla.Models.Actor + alias Nulla.Models.Note + + def create do + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test", + type: "Person", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", + preferredUsername: "test", + name: "Test", + summary: "Test User", + url: "http://localhost/@test", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + User.create_user(%{ + id: actor.id, + email: "test@localhost", + password: "password", + privateKeyPem: privateKeyPem, + last_active_at: DateTime.utc_now() + }) + + Note.create_note(%{ + actor_url: actor.url, + content: "Hello World from Nulla!", + language: "en", + actor_id: actor.id + }) + + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + {:ok, actor} = + Actor.create_actor(%{ + domain: "localhost", + ap_id: "http://localhost/users/test2", + type: "Person", + following: "http://localhost/users/test2/following", + followers: "http://localhost/users/test2/followers", + inbox: "http://localhost/users/test2/inbox", + outbox: "http://localhost/users/test2/outbox", + featured: "http://localhost/users/test2/collections/featured", + featuredTags: "http://localhost/users/test2/collections/tags", + preferredUsername: "test2", + name: "Test", + summary: "Test User", + url: "http://localhost/@test2", + manuallyApprovesFollowers: false, + discoverable: true, + indexable: true, + published: DateTime.utc_now(), + memorial: false, + publicKey: + Jason.OrderedObject.new( + id: "http://localhost/users/test2#main-key", + owner: "http://localhost/users/test2", + publicKeyPem: publicKeyPem + ), + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + }) + + User.create_user(%{ + id: actor.id, + email: "test2@localhost", + password: "password", + privateKeyPem: privateKeyPem, + last_active_at: DateTime.utc_now() + }) + end +end From 748baff8f3c9b03a6369176ddd28787d3ff74c37 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 08:35:12 +0200 Subject: [PATCH 129/144] Add inbox_controller_test.exs --- .../controllers/inbox_controller_test.exs | 71 +++++++++++++++++++ test/support/fixtures/data.ex | 50 +++++++------ 2 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 test/nulla_web/controllers/inbox_controller_test.exs diff --git a/test/nulla_web/controllers/inbox_controller_test.exs b/test/nulla_web/controllers/inbox_controller_test.exs new file mode 100644 index 0000000..57dea6b --- /dev/null +++ b/test/nulla_web/controllers/inbox_controller_test.exs @@ -0,0 +1,71 @@ +defmodule NullaWeb.InboxControllerTest do + use NullaWeb.ConnCase + alias Nulla.Snowflake + alias Nulla.Models.User + alias Nulla.Models.Actor + + setup do + Nulla.Fixtures.Data.create() + :ok + end + + describe "POST /users/username/inbox" do + test "Validate follow request", %{conn: conn} do + actor = Actor.get_actor(preferredUsername: "test") + target_actor = Actor.get_actor(preferredUsername: "test2") + user = User.get_user(id: actor.id) + + follow_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => Snowflake.next_id(), + "type" => "Follow", + "actor" => actor.ap_id, + "object" => target_actor.ap_id + } + + body = Jason.encode!(follow_activity) + + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT") + uri = URI.parse("/users/test2/inbox") + + signature_string = """ + (request-target): post #{uri.path} + host: localhost + date: #{date} + digest: #{digest} + """ + + private_key = + case :public_key.pem_decode(user.privateKeyPem) do + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{actor.publicKey["id"]}", + algorithm="rsa-sha256", + headers="(request-target) host date digest", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + conn = + conn + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("accept", "application/activity+json") + |> put_req_header("date", date) + |> put_req_header("digest", digest) + |> put_req_header("signature", signature_header) + |> post("/users/test2/inbox", body) + + assert conn.status == 200 + end + end +end diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex index 571888f..1327dca 100644 --- a/test/support/fixtures/data.ex +++ b/test/support/fixtures/data.ex @@ -5,23 +5,29 @@ defmodule Nulla.Fixtures.Data do alias Nulla.Models.Note def create do + endpoint_config = Application.fetch_env!(:nulla, NullaWeb.Endpoint) + ip = endpoint_config[:http][:ip] + host = :inet_parse.ntoa(ip) |> to_string() + port = endpoint_config[:http][:port] + base_url = "http://#{host}:#{port}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() {:ok, actor} = Actor.create_actor(%{ domain: "localhost", - ap_id: "http://localhost/users/test", + ap_id: "#{base_url}/users/test", type: "Person", - following: "http://localhost/users/test/following", - followers: "http://localhost/users/test/followers", - inbox: "http://localhost/users/test/inbox", - outbox: "http://localhost/users/test/outbox", - featured: "http://localhost/users/test/collections/featured", - featuredTags: "http://localhost/users/test/collections/tags", + following: "#{base_url}/users/test/following", + followers: "#{base_url}/users/test/followers", + inbox: "#{base_url}/users/test/inbox", + outbox: "#{base_url}/users/test/outbox", + featured: "#{base_url}/users/test/collections/featured", + featuredTags: "#{base_url}/users/test/collections/tags", preferredUsername: "test", name: "Test", summary: "Test User", - url: "http://localhost/@test", + url: "#{base_url}/@test", manuallyApprovesFollowers: false, discoverable: true, indexable: true, @@ -29,11 +35,11 @@ defmodule Nulla.Fixtures.Data do memorial: false, publicKey: Jason.OrderedObject.new( - id: "http://localhost/users/test#main-key", - owner: "http://localhost/users/test", + id: "#{base_url}/users/test#main-key", + owner: "#{base_url}/users/test", publicKeyPem: publicKeyPem ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + endpoints: Jason.OrderedObject.new(sharedInbox: "#{base_url}/inbox") }) User.create_user(%{ @@ -56,18 +62,18 @@ defmodule Nulla.Fixtures.Data do {:ok, actor} = Actor.create_actor(%{ domain: "localhost", - ap_id: "http://localhost/users/test2", + ap_id: "#{base_url}/users/test2", type: "Person", - following: "http://localhost/users/test2/following", - followers: "http://localhost/users/test2/followers", - inbox: "http://localhost/users/test2/inbox", - outbox: "http://localhost/users/test2/outbox", - featured: "http://localhost/users/test2/collections/featured", - featuredTags: "http://localhost/users/test2/collections/tags", + following: "#{base_url}/users/test2/following", + followers: "#{base_url}/users/test2/followers", + inbox: "#{base_url}/users/test2/inbox", + outbox: "#{base_url}/users/test2/outbox", + featured: "#{base_url}/users/test2/collections/featured", + featuredTags: "#{base_url}/users/test2/collections/tags", preferredUsername: "test2", name: "Test", summary: "Test User", - url: "http://localhost/@test2", + url: "#{base_url}/@test2", manuallyApprovesFollowers: false, discoverable: true, indexable: true, @@ -75,11 +81,11 @@ defmodule Nulla.Fixtures.Data do memorial: false, publicKey: Jason.OrderedObject.new( - id: "http://localhost/users/test2#main-key", - owner: "http://localhost/users/test2", + id: "#{base_url}/users/test2#main-key", + owner: "#{base_url}/users/test2", publicKeyPem: publicKeyPem ), - endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") + endpoints: Jason.OrderedObject.new(sharedInbox: "#{base_url}/inbox") }) User.create_user(%{ From 1faafeee26dc279c6287ae0980a33c45f355b4e7 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 14:59:33 +0200 Subject: [PATCH 130/144] Fix --- lib/nulla/http_signature.ex | 29 ++++++----- test/nulla/http_signature_test.exs | 40 ++++++++++++++ test/nulla/keys.exs | 32 ++++++++++++ .../controllers/actor_controller_test.exs | 3 +- .../controllers/follow_controller_test.exs | 3 +- .../controllers/inbox_controller_test.exs | 3 +- .../controllers/nodeinfo_controller_test.exs | 3 +- .../controllers/note_controller_test.exs | 3 +- .../controllers/outbox_controller_test.exs | 3 +- .../controllers/webfinger_controller_test.exs | 3 +- test/support/fixtures/data.ex | 52 ++++++++----------- 11 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 test/nulla/http_signature_test.exs create mode 100644 test/nulla/keys.exs diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex index e32e5b2..5207c2d 100644 --- a/lib/nulla/http_signature.ex +++ b/lib/nulla/http_signature.ex @@ -7,12 +7,11 @@ defmodule Nulla.HTTPSignature do date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") uri = URI.parse(inbox_url) - signature_string = """ - (request-target): post #{uri.path} - host: #{uri.host} - date: #{date} - digest: #{digest} - """ + signature_string = + "(request-target): post #{uri.path}\n" <> + "host: #{uri.host}\n" <> + "date: #{date}\n" <> + "digest: #{digest}" user = User.get_user(id: actor.id) @@ -56,13 +55,16 @@ defmodule Nulla.HTTPSignature do end defp parse_signature_header(header) do + header = + header + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {String.trim(k), String.trim(v, ~s("))} + end) + |> Enum.into(%{}) + header - |> String.split(",") - |> Enum.map(fn pair -> - [k, v] = String.split(pair, "=", parts: 2) - {String.trim(k), String.trim(v, ~s("))} - end) - |> Enum.into(%{}) end defp build_signature_string(nil, _conn), do: {:error, :missing_headers} @@ -83,6 +85,9 @@ defmodule Nulla.HTTPSignature do "(request-target): #{method} #{path}" + "host" -> + "host: #{conn.host}" + _ -> value = get_req_header(conn, header) |> List.first() if value, do: "#{header}: #{value}", else: nil diff --git a/test/nulla/http_signature_test.exs b/test/nulla/http_signature_test.exs new file mode 100644 index 0000000..2983a17 --- /dev/null +++ b/test/nulla/http_signature_test.exs @@ -0,0 +1,40 @@ +defmodule Nulla.HTTPSignatureTest do + use NullaWeb.ConnCase, async: false + import Plug.Conn + import Plug.Test + import Nulla.Fixtures.Data + alias Nulla.HTTPSignature + alias Nulla.Snowflake + alias Nulla.Models.Actor + + setup do + create_data() + :ok + end + + test "make_headers/3 creates valid signature headers and verify/2 validates them" do + actor = Actor.get_actor(preferredUsername: "test") + target_actor = Actor.get_actor(preferredUsername: "test2") + + follow_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => Snowflake.next_id(), + "type" => "Follow", + "actor" => actor.ap_id, + "object" => target_actor.ap_id + } + + body = Jason.encode!(follow_activity) + headers = HTTPSignature.make_headers(body, target_actor.inbox, actor) + + conn = + conn(:post, "/users/test2/inbox", body) + |> put_req_header("content-type", Map.new(headers) |> Map.get("Content-Type")) + |> put_req_header("date", Map.new(headers) |> Map.get("Date")) + |> put_req_header("digest", Map.new(headers) |> Map.get("Digest")) + |> put_req_header("signature", Map.new(headers) |> Map.get("Signature")) + |> Map.put(:host, URI.parse(target_actor.inbox).host) + + assert :ok = HTTPSignature.verify(conn, actor.publicKey["publicKeyPem"]) + end +end diff --git a/test/nulla/keys.exs b/test/nulla/keys.exs new file mode 100644 index 0000000..10f8083 --- /dev/null +++ b/test/nulla/keys.exs @@ -0,0 +1,32 @@ +defmodule Nulla.KeysTest do + use NullaWeb.ConnCase, async: false + import Nulla.Fixtures.Data + alias Nulla.Models.User + alias Nulla.Models.Actor + + setup do + create() + :ok + end + + test "verify user's keys" do + actor = Actor.get_actor(preferredUsername: "test") + user = User.get_user(id: actor.id) + + message = "test message" + + private_key = + :public_key.pem_decode(user.privateKeyPem) + |> hd() + |> :public_key.pem_entry_decode() + + public_key = + :public_key.pem_decode(actor.publicKey["publicKeyPem"]) + |> hd() + |> :public_key.pem_entry_decode() + + signature = :public_key.sign(message, :sha256, private_key) + + assert :public_key.verify(message, :sha256, signature, public_key) + end +end diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/actor_controller_test.exs index e2af38c..4204cb1 100644 --- a/test/nulla_web/controllers/actor_controller_test.exs +++ b/test/nulla_web/controllers/actor_controller_test.exs @@ -1,8 +1,9 @@ defmodule NullaWeb.ActorControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/follow_controller_test.exs b/test/nulla_web/controllers/follow_controller_test.exs index b285e7f..711148d 100644 --- a/test/nulla_web/controllers/follow_controller_test.exs +++ b/test/nulla_web/controllers/follow_controller_test.exs @@ -1,8 +1,9 @@ defmodule NullaWeb.FollowControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/inbox_controller_test.exs b/test/nulla_web/controllers/inbox_controller_test.exs index 57dea6b..14e2559 100644 --- a/test/nulla_web/controllers/inbox_controller_test.exs +++ b/test/nulla_web/controllers/inbox_controller_test.exs @@ -1,11 +1,12 @@ defmodule NullaWeb.InboxControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data alias Nulla.Snowflake alias Nulla.Models.User alias Nulla.Models.Actor setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/nodeinfo_controller_test.exs b/test/nulla_web/controllers/nodeinfo_controller_test.exs index 1fd570f..ab1d5d2 100644 --- a/test/nulla_web/controllers/nodeinfo_controller_test.exs +++ b/test/nulla_web/controllers/nodeinfo_controller_test.exs @@ -1,8 +1,9 @@ defmodule NullaWeb.NodeinfoControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs index d094f83..16c328b 100644 --- a/test/nulla_web/controllers/note_controller_test.exs +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -1,10 +1,11 @@ defmodule NullaWeb.NoteControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data alias Nulla.Models.Actor alias Nulla.Models.Note setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/outbox_controller_test.exs b/test/nulla_web/controllers/outbox_controller_test.exs index e8d0bd2..0318e96 100644 --- a/test/nulla_web/controllers/outbox_controller_test.exs +++ b/test/nulla_web/controllers/outbox_controller_test.exs @@ -1,8 +1,9 @@ defmodule NullaWeb.OutboxControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/nulla_web/controllers/webfinger_controller_test.exs b/test/nulla_web/controllers/webfinger_controller_test.exs index b0f70c7..7c7cc18 100644 --- a/test/nulla_web/controllers/webfinger_controller_test.exs +++ b/test/nulla_web/controllers/webfinger_controller_test.exs @@ -1,8 +1,9 @@ defmodule NullaWeb.WebfingerControllerTest do use NullaWeb.ConnCase + import Nulla.Fixtures.Data setup do - Nulla.Fixtures.Data.create() + create_data() :ok end diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex index 1327dca..8e40b58 100644 --- a/test/support/fixtures/data.ex +++ b/test/support/fixtures/data.ex @@ -4,30 +4,24 @@ defmodule Nulla.Fixtures.Data do alias Nulla.Models.Actor alias Nulla.Models.Note - def create do - endpoint_config = Application.fetch_env!(:nulla, NullaWeb.Endpoint) - ip = endpoint_config[:http][:ip] - host = :inet_parse.ntoa(ip) |> to_string() - port = endpoint_config[:http][:port] - base_url = "http://#{host}:#{port}" - + def create_data do {publicKeyPem, privateKeyPem} = KeyGen.gen() {:ok, actor} = Actor.create_actor(%{ domain: "localhost", - ap_id: "#{base_url}/users/test", + ap_id: "http://localhost/users/test", type: "Person", - following: "#{base_url}/users/test/following", - followers: "#{base_url}/users/test/followers", - inbox: "#{base_url}/users/test/inbox", - outbox: "#{base_url}/users/test/outbox", - featured: "#{base_url}/users/test/collections/featured", - featuredTags: "#{base_url}/users/test/collections/tags", + following: "http://localhost/users/test/following", + followers: "http://localhost/users/test/followers", + inbox: "http://localhost/users/test/inbox", + outbox: "http://localhost/users/test/outbox", + featured: "http://localhost/users/test/collections/featured", + featuredTags: "http://localhost/users/test/collections/tags", preferredUsername: "test", name: "Test", summary: "Test User", - url: "#{base_url}/@test", + url: "http://localhost/@test", manuallyApprovesFollowers: false, discoverable: true, indexable: true, @@ -35,11 +29,11 @@ defmodule Nulla.Fixtures.Data do memorial: false, publicKey: Jason.OrderedObject.new( - id: "#{base_url}/users/test#main-key", - owner: "#{base_url}/users/test", + id: "http://localhost/users/test#main-key", + owner: "http://localhost/users/test", publicKeyPem: publicKeyPem ), - endpoints: Jason.OrderedObject.new(sharedInbox: "#{base_url}/inbox") + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") }) User.create_user(%{ @@ -62,18 +56,18 @@ defmodule Nulla.Fixtures.Data do {:ok, actor} = Actor.create_actor(%{ domain: "localhost", - ap_id: "#{base_url}/users/test2", + ap_id: "http://localhost/users/test2", type: "Person", - following: "#{base_url}/users/test2/following", - followers: "#{base_url}/users/test2/followers", - inbox: "#{base_url}/users/test2/inbox", - outbox: "#{base_url}/users/test2/outbox", - featured: "#{base_url}/users/test2/collections/featured", - featuredTags: "#{base_url}/users/test2/collections/tags", + following: "http://localhost/users/test2/following", + followers: "http://localhost/users/test2/followers", + inbox: "http://localhost/users/test2/inbox", + outbox: "http://localhost/users/test2/outbox", + featured: "http://localhost/users/test2/collections/featured", + featuredTags: "http://localhost/users/test2/collections/tags", preferredUsername: "test2", name: "Test", summary: "Test User", - url: "#{base_url}/@test2", + url: "http://localhost/@test2", manuallyApprovesFollowers: false, discoverable: true, indexable: true, @@ -81,11 +75,11 @@ defmodule Nulla.Fixtures.Data do memorial: false, publicKey: Jason.OrderedObject.new( - id: "#{base_url}/users/test2#main-key", - owner: "#{base_url}/users/test2", + id: "http://localhost/users/test2#main-key", + owner: "http://localhost/users/test2", publicKeyPem: publicKeyPem ), - endpoints: Jason.OrderedObject.new(sharedInbox: "#{base_url}/inbox") + endpoints: Jason.OrderedObject.new(sharedInbox: "http://localhost/inbox") }) User.create_user(%{ From ecc3953bf1619cefcb8bdefc98fc142e455eb167 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 19:29:13 +0200 Subject: [PATCH 131/144] Update --- lib/nulla/models/actor.ex | 18 ++++++++++----- lib/nulla/models/relation.ex | 22 +++++++++++-------- lib/nulla_web/controllers/inbox_controller.ex | 3 ++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index 5c78142..5ffc15f 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -135,10 +135,8 @@ defmodule Nulla.Models.Actor do Repo.get_by(__MODULE__, by) end - def get_or_create_actor(actor_json) do - ap_id = actor_json["id"] - - case __MODULE__.get_actor(ap_id: ap_id) do + def get_or_create_actor(%{"id" => ap_id} = actor_json) when is_binary(ap_id) do + case get_actor(ap_id: ap_id) do nil -> params = actor_json @@ -146,13 +144,21 @@ defmodule Nulla.Models.Actor do |> Map.delete("id") |> Map.put("domain", URI.parse(ap_id).host) - case __MODULE__.create_actor(params) do + case create_actor(params) do {:ok, actor} -> {:ok, actor} {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} end actor -> - {:ok, actor} + updates = + actor_json + |> Map.delete("id") + |> Map.put("domain", URI.parse(ap_id).host) + + case changeset(actor, updates) |> Repo.update() do + {:ok, updated_actor} -> {:ok, updated_actor} + {:error, changeset} -> {:error, {:actor_update_failed, changeset}} + end end end end diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 506b283..3271d5d 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -65,20 +65,24 @@ defmodule Nulla.Models.Relation do Repo.get_by(__MODULE__, by) end - def get_or_create_relation(local_actor_id, remote_actor_id) do - case __MODULE__.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do + def get_or_create_relation(local_actor_id, remote_actor_id, opts \\ []) do + case get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do nil -> - case __MODULE__.create_relation(%{ - followed_by: true, - local_actor_id: local_actor_id, - remote_actor_id: remote_actor_id - }) do + attrs = + Keyword.merge([local_actor_id: local_actor_id, remote_actor_id: remote_actor_id], opts) + + case create_relation(attrs) do {:ok, relation} -> {:ok, relation} - {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} + {:error, changeset} -> {:error, {:relation_creation_failed, changeset}} end relation -> - {:ok, relation} + updates = Enum.into(opts, %{}) + + case changeset(relation, updates) |> Repo.update() do + {:ok, updated_relation} -> {:ok, updated_relation} + {:error, changeset} -> {:error, {:relation_update_failed, changeset}} + end end end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index 0323657..cbec289 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -107,7 +107,8 @@ defmodule NullaWeb.InboxController do actor: local_actor.ap_id, object: Jason.encode!(follow_activity) }), - {:ok, _relation} <- Relation.get_or_create_relation(local_actor.id, remote_actor.id) do + {:ok, _relation} <- + Relation.get_or_create_relation(local_actor.id, remote_actor.id, followed_by: true) do activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} body = Jason.encode!(ActivityPub.activity(activity)) headers = HTTPSignature.make_headers(body, remote_actor_json["inbox"], local_actor) From 6ed5abc17d79ff5120d08b22dbcebe6649ef0524 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 19:52:48 +0200 Subject: [PATCH 132/144] Update config/dev.exs --- config/dev.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index b9f3112..2f57cdb 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,13 +16,11 @@ config :nulla, Nulla.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. -host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000") config :nulla, NullaWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - url: [host: host, port: port], http: [ip: {127, 0, 0, 1}, port: port], check_origin: false, code_reloader: true, From 0c2777eb12b47cc47f8f836eb5ec209dc6a7b712 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 19:53:06 +0200 Subject: [PATCH 133/144] Update actor_controller.ex --- lib/nulla_web/controllers/actor_controller.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index 3771961..ee46d0a 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -2,6 +2,7 @@ defmodule NullaWeb.ActorController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.Actor + alias Nulla.Models.Relation alias Nulla.Models.Note alias Nulla.Models.InstanceSettings @@ -22,17 +23,17 @@ defmodule NullaWeb.ActorController do |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) else + following = Relation.count_following(actor.id) + followers = Relation.count_followers(actor.id) notes = Note.get_latest_notes(actor.id) - following = 0 - followers = 0 render( conn, :show, actor: actor, - notes: notes, following: following, followers: followers, + notes: notes, layout: false ) end From 144bfb57e24cd0a5a14c23a615086a014ef4ce03 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 20:53:48 +0200 Subject: [PATCH 134/144] Update activity --- lib/nulla/models/activity.ex | 4 +++- priv/repo/migrations/20250615131856_create_activities.exs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index d09bb89..44e21bb 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -11,6 +11,8 @@ defmodule Nulla.Models.Activity do field :type, :string field :actor, :string field :object, :string + field :to, :string + field :cc, :string timestamps() end @@ -18,7 +20,7 @@ defmodule Nulla.Models.Activity do @doc false def changeset(activity, attrs) do activity - |> cast(attrs, [:ap_id, :type, :actor, :object]) + |> cast(attrs, [:ap_id, :type, :actor, :object, :to, :cc]) |> validate_required([:ap_id, :type, :actor, :object]) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index 965df8f..9d95372 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -8,6 +8,8 @@ defmodule Nulla.Repo.Migrations.CreateActivities do add :type, :string, null: false add :actor, :string, null: false add :object, :text, null: false + add :to, :text + add :cc, :text timestamps() end From 4640e3a36bc302847fd1f5177031b24dd61d8a52 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 23:16:01 +0200 Subject: [PATCH 135/144] Update activity --- lib/nulla/models/activity.ex | 4 ++-- priv/repo/migrations/20250615131856_create_activities.exs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index 44e21bb..c69b2af 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -11,8 +11,8 @@ defmodule Nulla.Models.Activity do field :type, :string field :actor, :string field :object, :string - field :to, :string - field :cc, :string + field :to, {:array, :string} + field :cc, {:array, :string} timestamps() end diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index 9d95372..b21c662 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -8,8 +8,8 @@ defmodule Nulla.Repo.Migrations.CreateActivities do add :type, :string, null: false add :actor, :string, null: false add :object, :text, null: false - add :to, :text - add :cc, :text + add :to, {:array, :string} + add :cc, {:array, :string} timestamps() end From 02bdaef80d8bda33699355946ca069abb0e9bc4c Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Sun, 29 Jun 2025 23:39:05 +0200 Subject: [PATCH 136/144] Update note --- lib/nulla/activitypub.ex | 11 ++++------- lib/nulla/models/note.ex | 8 +++----- priv/repo/migrations/20250615131431_create_notes.exs | 4 +++- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index e746002..12ad121 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -96,15 +96,11 @@ defmodule Nulla.ActivityPub do type: "Note", summary: nil, inReplyTo: note.inReplyTo, - published: note.inserted_at, + published: note.published, url: note.url, attributedTo: note.actor.ap_id, - to: [ - "https://www.w3.org/ns/activitystreams#Public" - ], - cc: [ - "#{note.actor.ap_id}/followers" - ], + to: note.to, + cc: note.cc, sensitive: note.sensitive, content: note.content, contentMap: Jason.OrderedObject.new("#{note.language}": note.content), @@ -341,6 +337,7 @@ defmodule Nulla.ActivityPub do to: [ "https://www.w3.org/ns/activitystreams#Public" ], + cc: [], object: Jason.OrderedObject.new( id: "#{note.actor.ap_id}/notes/#{note.id}", diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index a109559..2b603e6 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -10,12 +10,10 @@ defmodule Nulla.Models.Note do @primary_key {:id, :integer, autogenerate: false} schema "notes" do field :inReplyTo, :string + field :published, :utc_datetime field :url, :string - - field :visibility, Ecto.Enum, - values: [:public, :unlisted, :followers, :private], - default: :public - + field :to, {:array, :string} + field :cc, {:array, :string} field :sensitive, :boolean, default: false field :content, :string field :language, :string diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index d872437..3115275 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -5,8 +5,10 @@ defmodule Nulla.Repo.Migrations.CreateNotes do create table(:notes, primary_key: false) do add :id, :bigint, primary_key: true add :inReplyTo, :string + add :published, :utc_datetime add :url, :string - add :visibility, :string, default: "public" + add :to, {:array, :string} + add :cc, {:array, :string} add :sensitive, :boolean, default: false add :content, :text add :language, :string From 20a3ed9e71f6e05768de7fe11b2977f60408f9ba Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 30 Jun 2025 12:01:26 +0200 Subject: [PATCH 137/144] Update note --- lib/nulla/models/note.ex | 54 +++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index 2b603e6..a054b87 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -27,28 +27,70 @@ defmodule Nulla.Models.Note do @doc false def changeset(note, attrs) do note - |> cast(attrs, [:content, :visibility, :sensitive, :language, :inReplyTo, :actor_id]) - |> validate_required([:content, :visibility, :sensitive, :language, :actor_id]) + |> cast(attrs, [ + :inReplyTo, + :published, + :url, + :to, + :cc, + :sensitive, + :content, + :language, + :actor_id + ]) + |> validate_required([:published, :url, :to, :cc, :content, :language, :actor_id]) end - def create_note(attrs) when is_map(attrs) do + def create_note(attrs, visibility) + when is_map(attrs) and visibility in ["public", "unlisted", "followers", "private"] do id = Map.get(attrs, :id, Snowflake.next_id()) + actor_id = Map.get(attrs, :actor_id) + actor = Actor.get_actor(id: actor_id) + published = Map.get(attrs, :published, DateTime.utc_now()) url = case Map.get(attrs, :url) do nil -> - actor_url = Map.get(attrs, :actor_url) - - "#{actor_url}/#{id}" + "#{actor.url}/#{id}" _ -> Map.get(attrs, :url) end + {to, cc} = + case visibility do + "public" -> + to = ["https://www.w3.org/ns/activitystreams#Public"] + cc = [actor.followers | Map.get(attrs, :cc, [])] + + {to, cc} + + "unlisted" -> + to = [actor.followers] + cc = ["https://www.w3.org/ns/activitystreams#Public" | Map.get(attrs, :cc, [])] + + {to, cc} + + "followers" -> + to = [actor.followers] + cc = Map.get(attrs, :cc, []) + + {to, cc} + + "private" -> + to = Map.get(attrs, :to, []) + cc = [] + + {to, cc} + end + %__MODULE__{} |> changeset(attrs) |> put_change(:id, id) + |> put_change(:published, published) |> put_change(:url, url) + |> put_change(:to, to) + |> put_change(:cc, cc) |> Repo.insert() end From fa350aa551cd92da80c093f054c5afb334c68a48 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 30 Jun 2025 13:18:06 +0200 Subject: [PATCH 138/144] Add sender --- lib/nulla/http_signature.ex | 9 ++-- lib/nulla/models/activity.ex | 3 +- lib/nulla/sender.ex | 24 ++++++++++ lib/nulla/types/string_or_json.ex | 46 +++++++++++++++++++ lib/nulla_web/controllers/inbox_controller.ex | 28 ++++------- 5 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 lib/nulla/sender.ex create mode 100644 lib/nulla/types/string_or_json.ex diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex index 5207c2d..35c3977 100644 --- a/lib/nulla/http_signature.ex +++ b/lib/nulla/http_signature.ex @@ -1,8 +1,7 @@ defmodule Nulla.HTTPSignature do import Plug.Conn - alias Nulla.Models.User - def make_headers(body, inbox_url, actor) do + def make_headers(body, inbox_url, publicKeyId, privateKeyPem) do digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") uri = URI.parse(inbox_url) @@ -13,10 +12,8 @@ defmodule Nulla.HTTPSignature do "date: #{date}\n" <> "digest: #{digest}" - user = User.get_user(id: actor.id) - private_key = - case :public_key.pem_decode(user.privateKeyPem) do + case :public_key.pem_decode(privateKeyPem) do [entry] -> :public_key.pem_entry_decode(entry) _ -> raise "Invalid PEM format" end @@ -27,7 +24,7 @@ defmodule Nulla.HTTPSignature do signature_header = """ - keyId="#{actor.publicKey["id"]}", + keyId="#{publicKeyId}", algorithm="rsa-sha256", headers="(request-target) host date digest", signature="#{signature}" diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index c69b2af..00add89 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -3,6 +3,7 @@ defmodule Nulla.Models.Activity do import Ecto.Changeset alias Nulla.Repo alias Nulla.Snowflake + alias Nulla.Types.StringOrJson @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} @primary_key {:id, :integer, autogenerate: false} @@ -10,7 +11,7 @@ defmodule Nulla.Models.Activity do field :ap_id, :string field :type, :string field :actor, :string - field :object, :string + field :object, StringOrJson field :to, {:array, :string} field :cc, {:array, :string} diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex new file mode 100644 index 0000000..8e3a516 --- /dev/null +++ b/lib/nulla/sender.ex @@ -0,0 +1,24 @@ +defmodule Nulla.Sender do + alias Nulla.ActivityPub + alias Nulla.HTTPSignature + + def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do + body = Jason.encode!(ActivityPub.activity(activity)) + headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) + request = Finch.build(method, inbox, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Activity #{activity.id} delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver activity #{activity.id}") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Activity #{activity.id} delivery failed") + {:error, reason} + end + end +end diff --git a/lib/nulla/types/string_or_json.ex b/lib/nulla/types/string_or_json.ex new file mode 100644 index 0000000..4463c32 --- /dev/null +++ b/lib/nulla/types/string_or_json.ex @@ -0,0 +1,46 @@ +defmodule Nulla.Types.StringOrJson do + @behaviour Ecto.Type + + @impl true + def type, do: :string + + @impl true + def cast(value) when is_map(value) or is_list(value), do: {:ok, value} + + @impl true + def cast(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def cast(_), do: :error + + @impl true + def dump(value) when is_map(value) or is_list(value), do: Jason.encode(value) + + @impl true + def dump(value) when is_binary(value), do: {:ok, value} + + @impl true + def dump(_), do: :error + + @impl true + def load(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def load(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2), do: term1 == term2 +end diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index cbec289..4cbbb44 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,9 +1,10 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller - alias Nulla.ActivityPub alias Nulla.Snowflake alias Nulla.HTTPSignature + alias Nulla.Sender alias Nulla.Utils + alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Activity @@ -109,24 +110,15 @@ defmodule NullaWeb.InboxController do }), {:ok, _relation} <- Relation.get_or_create_relation(local_actor.id, remote_actor.id, followed_by: true) do - activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} - body = Jason.encode!(ActivityPub.activity(activity)) - headers = HTTPSignature.make_headers(body, remote_actor_json["inbox"], local_actor) - request = Finch.build(:post, remote_actor_json["inbox"], headers, body) + user = User.get_user(id: local_actor.id) - case Finch.request(request, Nulla.Finch) do - {:ok, %Finch.Response{status: code}} when code in 200..299 -> - IO.puts("Accept delivered successfully") - :ok - - {:ok, %Finch.Response{status: code, body: resp}} -> - IO.inspect({:error, code, resp}, label: "Failed to deliver Accept") - {:error, {:http_error, code}} - - {:error, reason} -> - IO.inspect(reason, label: "Finch delivery failed") - {:error, reason} - end + Sender.send_activity( + :post, + remote_actor.inbox, + accept_activity, + local_actor.publicKey["id"], + user.privateKeyPem + ) send_resp(conn, 200, "") else From c531beadb7aee10223490c5e8b3c2e64fe79f3d6 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 30 Jun 2025 14:03:57 +0200 Subject: [PATCH 139/144] Update --- lib/nulla/models/instance_settings.ex | 3 --- lib/nulla/models/relation.ex | 4 +++- lib/nulla/utils.ex | 2 -- lib/nulla_web/controllers/actor_controller.ex | 4 +--- lib/nulla_web/controllers/auth_controller.ex | 2 +- lib/nulla_web/controllers/follow_controller.ex | 10 ++++------ lib/nulla_web/controllers/hostmeta_controller.ex | 4 +--- lib/nulla_web/controllers/nodeinfo_controller.ex | 3 +-- lib/nulla_web/controllers/outbox_controller.ex | 10 +++------- lib/nulla_web/controllers/webfinger_controller.ex | 9 ++++----- .../20250527054942_create_instance_settings.exs | 10 ++-------- 11 files changed, 20 insertions(+), 41 deletions(-) diff --git a/lib/nulla/models/instance_settings.ex b/lib/nulla/models/instance_settings.ex index 37ffe88..b46d3be 100644 --- a/lib/nulla/models/instance_settings.ex +++ b/lib/nulla/models/instance_settings.ex @@ -7,7 +7,6 @@ defmodule Nulla.Models.InstanceSettings do schema "instance_settings" do field :name, :string, default: "Nulla" field :description, :string, default: "Freedom Social Network" - field :domain, :string, default: "localhost" field :registration, :boolean, default: false field :max_characters, :integer, default: 5000 field :max_upload_size, :integer, default: 50 @@ -22,7 +21,6 @@ defmodule Nulla.Models.InstanceSettings do |> cast(attrs, [ :name, :description, - :domain, :registration, :max_characters, :max_upload_size, @@ -33,7 +31,6 @@ defmodule Nulla.Models.InstanceSettings do |> validate_required([ :name, :description, - :domain, :registration, :max_characters, :max_upload_size, diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 3271d5d..7d50f93 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -69,7 +69,9 @@ defmodule Nulla.Models.Relation do case get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do nil -> attrs = - Keyword.merge([local_actor_id: local_actor_id, remote_actor_id: remote_actor_id], opts) + [local_actor_id: local_actor_id, remote_actor_id: remote_actor_id] + |> Keyword.merge(opts) + |> Enum.into(%{}) case create_relation(attrs) do {:ok, relation} -> {:ok, relation} diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index 4915620..4dd8d22 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -1,6 +1,4 @@ defmodule Nulla.Utils do - alias Finch - def fetch_remote_actor(uri) do headers = [ {"Accept", "application/activity+json"}, diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index ee46d0a..bedcc0f 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -4,12 +4,10 @@ defmodule NullaWeb.ActorController do alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Note - alias Nulla.Models.InstanceSettings def show(conn, %{"username" => username}) do format = Phoenix.Controller.get_format(conn) - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() case Actor.get_actor(preferredUsername: username, domain: domain) do nil -> diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/auth_controller.ex index e766e56..8a6017d 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/auth_controller.ex @@ -39,7 +39,7 @@ defmodule NullaWeb.AuthController do |> put_flash(:error, "Registration is disabled.") |> redirect(to: ~p"/") else - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() hashed_password = Argon2.hash_pwd_salt(password) {publicKeyPem, privateKeyPem} = Nulla.KeyGen.gen() diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index d8ca5b4..22dc1c4 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -7,7 +7,7 @@ defmodule NullaWeb.FollowController do def following(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() limit = instance_settings.api_limit actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_following(actor.id) @@ -26,8 +26,7 @@ defmodule NullaWeb.FollowController do end def following(conn, %{"username" => username}) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_following(actor.id) @@ -38,7 +37,7 @@ defmodule NullaWeb.FollowController do def followers(conn, %{"username" => username, "page" => page_param}) do instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() limit = instance_settings.api_limit actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_followers(actor.id) @@ -57,8 +56,7 @@ defmodule NullaWeb.FollowController do end def followers(conn, %{"username" => username}) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Relation.count_followers(actor.id) diff --git a/lib/nulla_web/controllers/hostmeta_controller.ex b/lib/nulla_web/controllers/hostmeta_controller.ex index 8f7d835..cee64ce 100644 --- a/lib/nulla_web/controllers/hostmeta_controller.ex +++ b/lib/nulla_web/controllers/hostmeta_controller.ex @@ -1,10 +1,8 @@ defmodule NullaWeb.HostmetaController do use NullaWeb, :controller - alias Nulla.Models.InstanceSettings def index(conn, _params) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() xml = """ diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/nodeinfo_controller.ex index aaa68bd..f532473 100644 --- a/lib/nulla_web/controllers/nodeinfo_controller.ex +++ b/lib/nulla_web/controllers/nodeinfo_controller.ex @@ -5,8 +5,7 @@ defmodule NullaWeb.NodeinfoController do alias Nulla.Models.InstanceSettings def index(conn, _params) do - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain + domain = NullaWeb.Endpoint.host() json(conn, ActivityPub.nodeinfo(domain)) end diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/outbox_controller.ex index 093015d..d6b5791 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/outbox_controller.ex @@ -3,14 +3,13 @@ defmodule NullaWeb.OutboxController do alias Nulla.ActivityPub alias Nulla.Models.Actor alias Nulla.Models.Note - alias Nulla.Models.InstanceSettings def outbox(conn, %{"username" => username} = params) do + domain = NullaWeb.Endpoint.host() + actor = Actor.get_actor(preferredUsername: username, domain: domain) + case Map.get(params, "page") do "true" -> - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - actor = Actor.get_actor(preferredUsername: username, domain: domain) max_id = params["max_id"] && String.to_integer(params["max_id"]) notes = @@ -42,9 +41,6 @@ defmodule NullaWeb.OutboxController do ) _ -> - instance_settings = InstanceSettings.get_instance_settings!() - domain = instance_settings.domain - actor = Actor.get_actor(preferredUsername: username, domain: domain) total = Note.get_total_notes_count(actor.id) conn diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/webfinger_controller.ex index 3db0a28..c837146 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/webfinger_controller.ex @@ -2,21 +2,20 @@ defmodule NullaWeb.WebfingerController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.Actor - alias Nulla.Models.InstanceSettings def index(conn, %{"resource" => resource}) do case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do - [_, username, domain] -> - case Actor.get_actor(preferredUsername: username, domain: domain) do + [_, preferredUsername, actor_domain] -> + case Actor.get_actor(preferredUsername: preferredUsername, domain: actor_domain) do nil -> conn |> put_resp_content_type("text/plain") |> send_resp(404, "") %Actor{} = actor -> - instance_settings = InstanceSettings.get_instance_settings!() + domain = NullaWeb.Endpoint.host() - if domain == instance_settings.domain do + if actor_domain == domain do json(conn, ActivityPub.webfinger(actor)) else conn diff --git a/priv/repo/migrations/20250527054942_create_instance_settings.exs b/priv/repo/migrations/20250527054942_create_instance_settings.exs index e818f66..4a132be 100644 --- a/priv/repo/migrations/20250527054942_create_instance_settings.exs +++ b/priv/repo/migrations/20250527054942_create_instance_settings.exs @@ -6,7 +6,6 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do add :id, :integer, primary_key: true add :name, :string, default: "Nulla", null: false add :description, :text, default: "Freedom Social Network", null: false - add :domain, :string, default: "localhost", null: false add :registration, :boolean, default: false, null: false add :max_characters, :integer, default: 5000, null: false add :max_upload_size, :integer, default: 50, null: false @@ -25,20 +24,15 @@ defmodule Nulla.Repo.Migrations.CreateInstanceSettings do {public_key, private_key} = Nulla.KeyGen.gen() now = DateTime.utc_now() - domain = - Application.get_env(:nulla, NullaWeb.Endpoint, []) - |> Keyword.get(:url, []) - |> Keyword.get(:host, "localhost") - esc = fn str -> "'#{String.replace(str, "'", "''")}'" end sql = """ INSERT INTO instance_settings ( - id, name, description, domain, registration, + id, name, description, registration, max_characters, max_upload_size, api_limit, public_key, private_key, inserted_at, updated_at ) VALUES ( - 1, 'Nulla', 'Freedom Social Network', '#{domain}', false, + 1, 'Nulla', 'Freedom Social Network', false, 5000, 50, 100, #{esc.(public_key)}, #{esc.(private_key)}, '#{now}', '#{now}' From dc25c926ba2b5d74fdf26af0b91a3b15f67b4612 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 30 Jun 2025 14:33:13 +0200 Subject: [PATCH 140/144] Fix --- lib/nulla/models/note.ex | 23 +++++++++++++------ .../components/templates/actor/show.html.heex | 8 +++---- .../20250615131431_create_notes.exs | 1 + test/nulla/http_signature_test.exs | 11 ++++++++- test/support/fixtures/data.ex | 3 ++- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/nulla/models/note.ex b/lib/nulla/models/note.ex index a054b87..9eb9468 100644 --- a/lib/nulla/models/note.ex +++ b/lib/nulla/models/note.ex @@ -12,6 +12,7 @@ defmodule Nulla.Models.Note do field :inReplyTo, :string field :published, :utc_datetime field :url, :string + field :visibility, :string field :to, {:array, :string} field :cc, {:array, :string} field :sensitive, :boolean, default: false @@ -28,9 +29,11 @@ defmodule Nulla.Models.Note do def changeset(note, attrs) do note |> cast(attrs, [ + :id, :inReplyTo, :published, :url, + :visibility, :to, :cc, :sensitive, @@ -41,12 +44,12 @@ defmodule Nulla.Models.Note do |> validate_required([:published, :url, :to, :cc, :content, :language, :actor_id]) end - def create_note(attrs, visibility) - when is_map(attrs) and visibility in ["public", "unlisted", "followers", "private"] do + def create_note(attrs) when is_map(attrs) do id = Map.get(attrs, :id, Snowflake.next_id()) actor_id = Map.get(attrs, :actor_id) actor = Actor.get_actor(id: actor_id) published = Map.get(attrs, :published, DateTime.utc_now()) + visibility = Map.get(attrs, :visibility, "public") url = case Map.get(attrs, :url) do @@ -82,15 +85,21 @@ defmodule Nulla.Models.Note do cc = [] {to, cc} + + _ -> + raise ArgumentError, "Invalid visibility: #{visibility}" end + attrs = + attrs + |> Map.put(:id, id) + |> Map.put(:published, published) + |> Map.put(:url, url) + |> Map.put(:to, to) + |> Map.put(:cc, cc) + %__MODULE__{} |> changeset(attrs) - |> put_change(:id, id) - |> put_change(:published, published) - |> put_change(:url, url) - |> put_change(:to, to) - |> put_change(:cc, cc) |> Repo.insert() end diff --git a/lib/nulla_web/components/templates/actor/show.html.heex b/lib/nulla_web/components/templates/actor/show.html.heex index d7a2d21..6f667cd 100644 --- a/lib/nulla_web/components/templates/actor/show.html.heex +++ b/lib/nulla_web/components/templates/actor/show.html.heex @@ -95,13 +95,13 @@
<%= case note.visibility do %> - <% :public -> %> + <% "public" -> %> <.icon name="hero-globe-americas" class="h-5 w-5" /> - <% :unlisted -> %> + <% "unlisted" -> %> <.icon name="hero-moon" class="h-5 w-5" /> - <% :followers -> %> + <% "followers" -> %> <.icon name="hero-lock-closed" class="h-5 w-5" /> - <% :private -> %> + <% "private" -> %> <.icon name="hero-at-symbol" class="h-5 w-5" /> <% end %> {format_note_datetime_diff(note.inserted_at)} diff --git a/priv/repo/migrations/20250615131431_create_notes.exs b/priv/repo/migrations/20250615131431_create_notes.exs index 3115275..2513289 100644 --- a/priv/repo/migrations/20250615131431_create_notes.exs +++ b/priv/repo/migrations/20250615131431_create_notes.exs @@ -7,6 +7,7 @@ defmodule Nulla.Repo.Migrations.CreateNotes do add :inReplyTo, :string add :published, :utc_datetime add :url, :string + add :visibility, :string add :to, {:array, :string} add :cc, {:array, :string} add :sensitive, :boolean, default: false diff --git a/test/nulla/http_signature_test.exs b/test/nulla/http_signature_test.exs index 2983a17..afab9df 100644 --- a/test/nulla/http_signature_test.exs +++ b/test/nulla/http_signature_test.exs @@ -5,6 +5,7 @@ defmodule Nulla.HTTPSignatureTest do import Nulla.Fixtures.Data alias Nulla.HTTPSignature alias Nulla.Snowflake + alias Nulla.Models.User alias Nulla.Models.Actor setup do @@ -15,6 +16,7 @@ defmodule Nulla.HTTPSignatureTest do test "make_headers/3 creates valid signature headers and verify/2 validates them" do actor = Actor.get_actor(preferredUsername: "test") target_actor = Actor.get_actor(preferredUsername: "test2") + user = User.get_user(id: actor.id) follow_activity = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@ -25,7 +27,14 @@ defmodule Nulla.HTTPSignatureTest do } body = Jason.encode!(follow_activity) - headers = HTTPSignature.make_headers(body, target_actor.inbox, actor) + + headers = + HTTPSignature.make_headers( + body, + target_actor.inbox, + actor.publicKey["id"], + user.privateKeyPem + ) conn = conn(:post, "/users/test2/inbox", body) diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex index 8e40b58..d2944ab 100644 --- a/test/support/fixtures/data.ex +++ b/test/support/fixtures/data.ex @@ -48,7 +48,8 @@ defmodule Nulla.Fixtures.Data do actor_url: actor.url, content: "Hello World from Nulla!", language: "en", - actor_id: actor.id + actor_id: actor.id, + visibility: "public" }) {publicKeyPem, privateKeyPem} = KeyGen.gen() From 01c2c579333362c58bb4f517821b94b762c907c6 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Mon, 30 Jun 2025 14:42:41 +0200 Subject: [PATCH 141/144] Update config/dev.exs --- config/dev.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/dev.exs b/config/dev.exs index 2f57cdb..d62d0ae 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,11 +16,13 @@ config :nulla, Nulla.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. +host = System.get_env("PHX_HOST") || "localhost" port = String.to_integer(System.get_env("PORT") || "4000") config :nulla, NullaWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + url: [host: host, port: port], http: [ip: {127, 0, 0, 1}, port: port], check_origin: false, code_reloader: true, From ddfb58c04119348b085d87647458252dcc981773 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 1 Jul 2025 10:15:06 +0200 Subject: [PATCH 142/144] Change structure --- .../activitypub/actor_controller.ex | 21 +++++++++ .../{ => activitypub}/follow_controller.ex | 2 +- .../{ => activitypub}/hostmeta_controller.ex | 2 +- .../{ => activitypub}/inbox_controller.ex | 2 +- .../{ => activitypub}/nodeinfo_controller.ex | 2 +- .../activitypub/note_controller.ex | 44 +++++++++++++++++++ .../{ => activitypub}/outbox_controller.ex | 2 +- .../{ => activitypub}/webfinger_controller.ex | 2 +- lib/nulla_web/controllers/actor_controller.ex | 40 ----------------- .../controllers/api/note_controller.ex | 33 ++++++++++++++ .../controllers/web/actor_controller.ex | 32 ++++++++++++++ .../controllers/{ => web}/auth_controller.ex | 2 +- .../controllers/{ => web}/note_controller.ex | 2 +- .../controllers/{ => web}/page_controller.ex | 2 +- lib/nulla_web/router.ex | 26 +++++------ test/support/fixtures/data.ex | 5 +-- 16 files changed, 153 insertions(+), 66 deletions(-) create mode 100644 lib/nulla_web/controllers/activitypub/actor_controller.ex rename lib/nulla_web/controllers/{ => activitypub}/follow_controller.ex (97%) rename lib/nulla_web/controllers/{ => activitypub}/hostmeta_controller.ex (89%) rename lib/nulla_web/controllers/{ => activitypub}/inbox_controller.ex (99%) rename lib/nulla_web/controllers/{ => activitypub}/nodeinfo_controller.ex (92%) create mode 100644 lib/nulla_web/controllers/activitypub/note_controller.ex rename lib/nulla_web/controllers/{ => activitypub}/outbox_controller.ex (96%) rename lib/nulla_web/controllers/{ => activitypub}/webfinger_controller.ex (94%) delete mode 100644 lib/nulla_web/controllers/actor_controller.ex create mode 100644 lib/nulla_web/controllers/api/note_controller.ex create mode 100644 lib/nulla_web/controllers/web/actor_controller.ex rename lib/nulla_web/controllers/{ => web}/auth_controller.ex (97%) rename lib/nulla_web/controllers/{ => web}/note_controller.ex (96%) rename lib/nulla_web/controllers/{ => web}/page_controller.ex (86%) diff --git a/lib/nulla_web/controllers/activitypub/actor_controller.ex b/lib/nulla_web/controllers/activitypub/actor_controller.ex new file mode 100644 index 0000000..6012e78 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/actor_controller.ex @@ -0,0 +1,21 @@ +defmodule NullaWeb.ActivityPub.ActorController do + use NullaWeb, :controller + alias Nulla.ActivityPub + alias Nulla.Models.Actor + + def show(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + + case Actor.get_actor(preferredUsername: username, domain: domain) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + %Actor{} = actor -> + conn + |> put_resp_content_type("application/activity+json") + |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) + end + end +end diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/activitypub/follow_controller.ex similarity index 97% rename from lib/nulla_web/controllers/follow_controller.ex rename to lib/nulla_web/controllers/activitypub/follow_controller.ex index 22dc1c4..e12535d 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/activitypub/follow_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.FollowController do +defmodule NullaWeb.ActivityPub.FollowController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.Actor diff --git a/lib/nulla_web/controllers/hostmeta_controller.ex b/lib/nulla_web/controllers/activitypub/hostmeta_controller.ex similarity index 89% rename from lib/nulla_web/controllers/hostmeta_controller.ex rename to lib/nulla_web/controllers/activitypub/hostmeta_controller.ex index cee64ce..fb573e8 100644 --- a/lib/nulla_web/controllers/hostmeta_controller.ex +++ b/lib/nulla_web/controllers/activitypub/hostmeta_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.HostmetaController do +defmodule NullaWeb.ActivityPub.HostmetaController do use NullaWeb, :controller def index(conn, _params) do diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/activitypub/inbox_controller.ex similarity index 99% rename from lib/nulla_web/controllers/inbox_controller.ex rename to lib/nulla_web/controllers/activitypub/inbox_controller.ex index 4cbbb44..d40170c 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/activitypub/inbox_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.InboxController do +defmodule NullaWeb.ActivityPub.InboxController do use NullaWeb, :controller alias Nulla.Snowflake alias Nulla.HTTPSignature diff --git a/lib/nulla_web/controllers/nodeinfo_controller.ex b/lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex similarity index 92% rename from lib/nulla_web/controllers/nodeinfo_controller.ex rename to lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex index f532473..aafc7f8 100644 --- a/lib/nulla_web/controllers/nodeinfo_controller.ex +++ b/lib/nulla_web/controllers/activitypub/nodeinfo_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.NodeinfoController do +defmodule NullaWeb.ActivityPub.NodeinfoController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.User diff --git a/lib/nulla_web/controllers/activitypub/note_controller.ex b/lib/nulla_web/controllers/activitypub/note_controller.ex new file mode 100644 index 0000000..48185b0 --- /dev/null +++ b/lib/nulla_web/controllers/activitypub/note_controller.ex @@ -0,0 +1,44 @@ +defmodule NullaWeb.ActivityPub.NoteController do + use NullaWeb, :controller + alias Nulla.Repo + alias Nulla.ActivityPub + alias Nulla.Models.Note + + def show(conn, %{"username" => username, "id" => id}) do + case Integer.parse(id) do + {int_id, ""} -> + note = Note.get_note(id: int_id) |> Repo.preload([:actor, :media_attachments]) + + cond do + is_nil(note) -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() + + username != note.actor.preferredUsername -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() + + true -> + format = Phoenix.Controller.get_format(conn) + + if format == "activity+json" do + conn + |> put_resp_content_type("application/activity+json") + |> json(ActivityPub.note(note)) + else + render(conn, :show, note: note, layout: false) + end + end + + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + |> halt() + end + end +end diff --git a/lib/nulla_web/controllers/outbox_controller.ex b/lib/nulla_web/controllers/activitypub/outbox_controller.ex similarity index 96% rename from lib/nulla_web/controllers/outbox_controller.ex rename to lib/nulla_web/controllers/activitypub/outbox_controller.ex index d6b5791..7243ca2 100644 --- a/lib/nulla_web/controllers/outbox_controller.ex +++ b/lib/nulla_web/controllers/activitypub/outbox_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.OutboxController do +defmodule NullaWeb.ActivityPub.OutboxController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.Actor diff --git a/lib/nulla_web/controllers/webfinger_controller.ex b/lib/nulla_web/controllers/activitypub/webfinger_controller.ex similarity index 94% rename from lib/nulla_web/controllers/webfinger_controller.ex rename to lib/nulla_web/controllers/activitypub/webfinger_controller.ex index c837146..fd4459d 100644 --- a/lib/nulla_web/controllers/webfinger_controller.ex +++ b/lib/nulla_web/controllers/activitypub/webfinger_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.WebfingerController do +defmodule NullaWeb.ActivityPub.WebfingerController do use NullaWeb, :controller alias Nulla.ActivityPub alias Nulla.Models.Actor diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex deleted file mode 100644 index bedcc0f..0000000 --- a/lib/nulla_web/controllers/actor_controller.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule NullaWeb.ActorController do - use NullaWeb, :controller - alias Nulla.ActivityPub - alias Nulla.Models.Actor - alias Nulla.Models.Relation - alias Nulla.Models.Note - - def show(conn, %{"username" => username}) do - format = Phoenix.Controller.get_format(conn) - domain = NullaWeb.Endpoint.host() - - case Actor.get_actor(preferredUsername: username, domain: domain) do - nil -> - conn - |> put_status(:not_found) - |> json(%{error: "Not Found"}) - - %Actor{} = actor -> - if format == "activity+json" do - conn - |> put_resp_content_type("application/activity+json") - |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) - else - following = Relation.count_following(actor.id) - followers = Relation.count_followers(actor.id) - notes = Note.get_latest_notes(actor.id) - - render( - conn, - :show, - actor: actor, - following: following, - followers: followers, - notes: notes, - layout: false - ) - end - end - end -end diff --git a/lib/nulla_web/controllers/api/note_controller.ex b/lib/nulla_web/controllers/api/note_controller.ex new file mode 100644 index 0000000..6a5b6f5 --- /dev/null +++ b/lib/nulla_web/controllers/api/note_controller.ex @@ -0,0 +1,33 @@ +defmodule NullaWeb.Api.NoteController do + use NullaWeb, :controller + alias Nulla.Models.Note + + def index(conn, _params) do + json(conn, []) + end + + def show(conn, %{"id" => id}) do + note = Note.get_note(id: id) + + case note do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + %Note{} = note -> + IO.inspect note + + json(conn, %{}) + end + end + + def create(_conn, _params) do + end + + def update(_conn, _params) do + end + + def delete(_conn, _params) do + end +end diff --git a/lib/nulla_web/controllers/web/actor_controller.ex b/lib/nulla_web/controllers/web/actor_controller.ex new file mode 100644 index 0000000..e42a775 --- /dev/null +++ b/lib/nulla_web/controllers/web/actor_controller.ex @@ -0,0 +1,32 @@ +defmodule NullaWeb.Web.ActorController do + use NullaWeb, :controller + alias Nulla.Models.Actor + alias Nulla.Models.Relation + alias Nulla.Models.Note + + def show(conn, %{"username" => username}) do + domain = NullaWeb.Endpoint.host() + + case Actor.get_actor(preferredUsername: username, domain: domain) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Not Found"}) + + %Actor{} = actor -> + following = Relation.count_following(actor.id) + followers = Relation.count_followers(actor.id) + notes = Note.get_latest_notes(actor.id) + + render( + conn, + :show, + actor: actor, + following: following, + followers: followers, + notes: notes, + layout: false + ) + end + end +end diff --git a/lib/nulla_web/controllers/auth_controller.ex b/lib/nulla_web/controllers/web/auth_controller.ex similarity index 97% rename from lib/nulla_web/controllers/auth_controller.ex rename to lib/nulla_web/controllers/web/auth_controller.ex index 8a6017d..99e055c 100644 --- a/lib/nulla_web/controllers/auth_controller.ex +++ b/lib/nulla_web/controllers/web/auth_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.AuthController do +defmodule NullaWeb.Web.AuthController do use NullaWeb, :controller alias Nulla.Models.User alias Nulla.Models.Actor diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/web/note_controller.ex similarity index 96% rename from lib/nulla_web/controllers/note_controller.ex rename to lib/nulla_web/controllers/web/note_controller.ex index f76a56a..ceb13e5 100644 --- a/lib/nulla_web/controllers/note_controller.ex +++ b/lib/nulla_web/controllers/web/note_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.NoteController do +defmodule NullaWeb.Web.NoteController do use NullaWeb, :controller alias Nulla.Repo alias Nulla.ActivityPub diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/web/page_controller.ex similarity index 86% rename from lib/nulla_web/controllers/page_controller.ex rename to lib/nulla_web/controllers/web/page_controller.ex index 1212f3d..3649c08 100644 --- a/lib/nulla_web/controllers/page_controller.ex +++ b/lib/nulla_web/controllers/web/page_controller.ex @@ -1,4 +1,4 @@ -defmodule NullaWeb.PageController do +defmodule NullaWeb.Web.PageController do use NullaWeb, :controller def home(conn, _params) do diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 31448b2..150e99b 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -6,8 +6,8 @@ defmodule NullaWeb.Router do plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} - # plug :protect_from_forgery - # plug :put_secure_browser_headers + plug :protect_from_forgery + plug :put_secure_browser_headers end pipeline :api do @@ -18,16 +18,11 @@ defmodule NullaWeb.Router do plug :accepts, ["activity+json"] end - scope "/", NullaWeb do + scope "/", NullaWeb.Web, as: :web do pipe_through :browser get "/", PageController, :home - get "/.well-known/host-meta", HostmetaController, :index - get "/.well-known/webfinger", WebfingerController, :index - get "/.well-known/nodeinfo", NodeinfoController, :index - get "/nodeinfo/2.0", NodeinfoController, :show - scope "/auth" do post "/", AuthController, :sign_up post "/sign_in", AuthController, :sign_in @@ -38,21 +33,24 @@ defmodule NullaWeb.Router do scope "/@:username" do get "/", ActorController, :show - get "/following", FollowController, :following - get "/followers", FollowController, :followers - post "/inbox", InboxController, :inbox - get "/outbox", OutboxController, :outbox get "/:id", NoteController, :show end end - scope "/api", NullaWeb do + scope "/api", NullaWeb.Api, as: :api do pipe_through :api + + resources "/notes", NoteController end - scope "/", NullaWeb do + scope "/", NullaWeb.ActivityPub, as: :activitypub do pipe_through :activitypub + get "/.well-known/host-meta", HostmetaController, :index + get "/.well-known/webfinger", WebfingerController, :index + get "/.well-known/nodeinfo", NodeinfoController, :index + get "/nodeinfo/2.0", NodeinfoController, :show + post "/inbox", InboxController, :inbox scope "/users/:username" do diff --git a/test/support/fixtures/data.ex b/test/support/fixtures/data.ex index d2944ab..11f26f5 100644 --- a/test/support/fixtures/data.ex +++ b/test/support/fixtures/data.ex @@ -45,11 +45,10 @@ defmodule Nulla.Fixtures.Data do }) Note.create_note(%{ - actor_url: actor.url, + visibility: "public", content: "Hello World from Nulla!", language: "en", - actor_id: actor.id, - visibility: "public" + actor_id: actor.id }) {publicKeyPem, privateKeyPem} = KeyGen.gen() From b35e18cd200f69cda08d2ad247729e7ccba6d4b0 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Tue, 1 Jul 2025 11:29:54 +0200 Subject: [PATCH 143/144] Init --- .formatter.exs | 6 + .gitignore | 37 + README.md | 18 + assets/css/app.css | 5 + assets/js/app.js | 44 ++ assets/tailwind.config.js | 74 ++ assets/vendor/topbar.js | 165 +++++ config/config.exs | 66 ++ config/dev.exs | 85 +++ config/prod.exs | 20 + config/runtime.exs | 117 +++ config/test.exs | 37 + lib/nulla.ex | 9 + lib/nulla/application.ex | 36 + lib/nulla/mailer.ex | 3 + lib/nulla/repo.ex | 5 + lib/nulla_web.ex | 116 +++ lib/nulla_web/components/core_components.ex | 676 ++++++++++++++++++ lib/nulla_web/components/layouts.ex | 14 + .../components/layouts/app.html.heex | 32 + .../components/layouts/root.html.heex | 17 + lib/nulla_web/controllers/error_html.ex | 24 + lib/nulla_web/controllers/error_json.ex | 21 + lib/nulla_web/controllers/page_controller.ex | 9 + lib/nulla_web/controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 222 ++++++ lib/nulla_web/endpoint.ex | 53 ++ lib/nulla_web/gettext.ex | 25 + lib/nulla_web/router.ex | 44 ++ lib/nulla_web/telemetry.ex | 93 +++ mix.exs | 85 +++ mix.lock | 41 ++ priv/gettext/en/LC_MESSAGES/errors.po | 112 +++ priv/gettext/errors.pot | 109 +++ priv/repo/migrations/.formatter.exs | 4 + priv/repo/seeds.exs | 11 + priv/static/favicon.ico | Bin 0 -> 152 bytes priv/static/images/logo.svg | 6 + priv/static/robots.txt | 5 + .../nulla_web/controllers/error_html_test.exs | 14 + .../nulla_web/controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + test/support/conn_case.ex | 38 + test/support/data_case.ex | 58 ++ test/test_helper.exs | 2 + 45 files changed, 2588 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/tailwind.config.js create mode 100644 assets/vendor/topbar.js create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/nulla.ex create mode 100644 lib/nulla/application.ex create mode 100644 lib/nulla/mailer.ex create mode 100644 lib/nulla/repo.ex create mode 100644 lib/nulla_web.ex create mode 100644 lib/nulla_web/components/core_components.ex create mode 100644 lib/nulla_web/components/layouts.ex create mode 100644 lib/nulla_web/components/layouts/app.html.heex create mode 100644 lib/nulla_web/components/layouts/root.html.heex create mode 100644 lib/nulla_web/controllers/error_html.ex create mode 100644 lib/nulla_web/controllers/error_json.ex create mode 100644 lib/nulla_web/controllers/page_controller.ex create mode 100644 lib/nulla_web/controllers/page_html.ex create mode 100644 lib/nulla_web/controllers/page_html/home.html.heex create mode 100644 lib/nulla_web/endpoint.ex create mode 100644 lib/nulla_web/gettext.ex create mode 100644 lib/nulla_web/router.ex create mode 100644 lib/nulla_web/telemetry.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/images/logo.svg create mode 100644 priv/static/robots.txt create mode 100644 test/nulla_web/controllers/error_html_test.exs create mode 100644 test/nulla_web/controllers/error_json_test.exs create mode 100644 test/nulla_web/controllers/page_controller_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3111c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +nulla-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d465c --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Nulla + +To start your Phoenix server: + + * Run `mix setup` to install and setup dependencies + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..d5e278a --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,44 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..0d71fd9 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,74 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/nulla_web.ex", + "../lib/nulla_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..b8cf59b --- /dev/null +++ b/config/config.exs @@ -0,0 +1,66 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :nulla, + ecto_repos: [Nulla.Repo], + generators: [timestamp_type: :utc_datetime] + +# Configures the endpoint +config :nulla, NullaWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON], + layout: false + ], + pubsub_server: Nulla.PubSub, + live_view: [signing_salt: "rmaJ4fGm"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :nulla, Nulla.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + nulla: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + nulla: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..200ebfd --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,85 @@ +import Config + +# Configure your database +config :nulla, Nulla.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "nulla_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :nulla, NullaWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "lqclOxhFj2lh7RLyFeE47uMJTjyt298gBeH659w0B3/RgGH+bWB9GmYGl4ILMkqK", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:nulla, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:nulla, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :nulla, NullaWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/nulla_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :nulla, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d52a3f9 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,20 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :nulla, NullaWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Nulla.Finch + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..0a45eea --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,117 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/nulla start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :nulla, NullaWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :nulla, Nulla.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :nulla, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :nulla, NullaWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :nulla, NullaWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :nulla, NullaWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :nulla, Nulla.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..c0f45e2 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,37 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :nulla, Nulla.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "nulla_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :nulla, NullaWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "DshBXGErBJik2O+qnSRsffwx9/TZDy8anaxLwB0uQSEXJsYmCtZFwR89fL5LtoSA", + server: false + +# In test we don't send emails +config :nulla, Nulla.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/lib/nulla.ex b/lib/nulla.ex new file mode 100644 index 0000000..ceb0132 --- /dev/null +++ b/lib/nulla.ex @@ -0,0 +1,9 @@ +defmodule Nulla do + @moduledoc """ + Nulla keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex new file mode 100644 index 0000000..7e86858 --- /dev/null +++ b/lib/nulla/application.ex @@ -0,0 +1,36 @@ +defmodule Nulla.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + NullaWeb.Telemetry, + Nulla.Repo, + {DNSCluster, query: Application.get_env(:nulla, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Nulla.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: Nulla.Finch}, + # Start a worker by calling: Nulla.Worker.start_link(arg) + # {Nulla.Worker, arg}, + # Start to serve requests, typically the last entry + NullaWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Nulla.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + NullaWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/nulla/mailer.ex b/lib/nulla/mailer.ex new file mode 100644 index 0000000..53f4221 --- /dev/null +++ b/lib/nulla/mailer.ex @@ -0,0 +1,3 @@ +defmodule Nulla.Mailer do + use Swoosh.Mailer, otp_app: :nulla +end diff --git a/lib/nulla/repo.ex b/lib/nulla/repo.ex new file mode 100644 index 0000000..76b8b7a --- /dev/null +++ b/lib/nulla/repo.ex @@ -0,0 +1,5 @@ +defmodule Nulla.Repo do + use Ecto.Repo, + otp_app: :nulla, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/nulla_web.ex b/lib/nulla_web.ex new file mode 100644 index 0000000..862aea6 --- /dev/null +++ b/lib/nulla_web.ex @@ -0,0 +1,116 @@ +defmodule NullaWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use NullaWeb, :controller + use NullaWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: NullaWeb.Layouts] + + use Gettext, backend: NullaWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {NullaWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # Translation + use Gettext, backend: NullaWeb.Gettext + + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import NullaWeb.CoreComponents + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: NullaWeb.Endpoint, + router: NullaWeb.Router, + statics: NullaWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/nulla_web/components/core_components.ex b/lib/nulla_web/components/core_components.ex new file mode 100644 index 0000000..3687e1e --- /dev/null +++ b/lib/nulla_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule NullaWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + use Gettext, backend: NullaWeb.Gettext + + alias Phoenix.LiveView.JS + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+
+ + + {render_slot(col, @row_item.(row))} + +
+
+
+ + + {render_slot(action, @row_item.(row))} + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
{item.title}
+
{render_slot(item)}
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + {render_slot(@inner_block)} + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/lib/nulla_web/components/layouts.ex b/lib/nulla_web/components/layouts.ex new file mode 100644 index 0000000..0e335fa --- /dev/null +++ b/lib/nulla_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule NullaWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use NullaWeb, :controller` and + `use NullaWeb, :live_view`. + """ + use NullaWeb, :html + + embed_templates "layouts/*" +end diff --git a/lib/nulla_web/components/layouts/app.html.heex b/lib/nulla_web/components/layouts/app.html.heex new file mode 100644 index 0000000..3b3b607 --- /dev/null +++ b/lib/nulla_web/components/layouts/app.html.heex @@ -0,0 +1,32 @@ +
+
+
+ + + +

+ v{Application.spec(:phoenix, :vsn)} +

+
+ +
+
+
+
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex new file mode 100644 index 0000000..ea6a36d --- /dev/null +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title default="Nulla" suffix=" · Phoenix Framework"> + {assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/lib/nulla_web/controllers/error_html.ex b/lib/nulla_web/controllers/error_html.ex new file mode 100644 index 0000000..1b0e1c8 --- /dev/null +++ b/lib/nulla_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule NullaWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use NullaWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/nulla_web/controllers/error_html/404.html.heex + # * lib/nulla_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/nulla_web/controllers/error_json.ex b/lib/nulla_web/controllers/error_json.ex new file mode 100644 index 0000000..be9d1c3 --- /dev/null +++ b/lib/nulla_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule NullaWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/nulla_web/controllers/page_controller.ex b/lib/nulla_web/controllers/page_controller.ex new file mode 100644 index 0000000..3daa035 --- /dev/null +++ b/lib/nulla_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule NullaWeb.PageController do + use NullaWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/lib/nulla_web/controllers/page_html.ex b/lib/nulla_web/controllers/page_html.ex new file mode 100644 index 0000000..fbb6c6e --- /dev/null +++ b/lib/nulla_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule NullaWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use NullaWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/nulla_web/controllers/page_html/home.html.heex b/lib/nulla_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..d72b03c --- /dev/null +++ b/lib/nulla_web/controllers/page_html/home.html.heex @@ -0,0 +1,222 @@ +<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/lib/nulla_web/endpoint.ex b/lib/nulla_web/endpoint.ex new file mode 100644 index 0000000..361acc5 --- /dev/null +++ b/lib/nulla_web/endpoint.ex @@ -0,0 +1,53 @@ +defmodule NullaWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :nulla + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_nulla_key", + signing_salt: "FuDu07Pq", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :nulla, + gzip: false, + only: NullaWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :nulla + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug NullaWeb.Router +end diff --git a/lib/nulla_web/gettext.ex b/lib/nulla_web/gettext.ex new file mode 100644 index 0000000..45a98d5 --- /dev/null +++ b/lib/nulla_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: NullaWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :nulla +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex new file mode 100644 index 0000000..95706c9 --- /dev/null +++ b/lib/nulla_web/router.ex @@ -0,0 +1,44 @@ +defmodule NullaWeb.Router do + use NullaWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {NullaWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", NullaWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", NullaWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:nulla, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: NullaWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/nulla_web/telemetry.ex b/lib/nulla_web/telemetry.ex new file mode 100644 index 0000000..0b8fc96 --- /dev/null +++ b/lib/nulla_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule NullaWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("nulla.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("nulla.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("nulla.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("nulla.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("nulla.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {NullaWeb, :count_users, []} + ] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..09ceeec --- /dev/null +++ b/mix.exs @@ -0,0 +1,85 @@ +defmodule Nulla.MixProject do + use Mix.Project + + def project do + [ + app: :nulla, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Nulla.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.21"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, + {:finch, "~> 0.13"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind nulla", "esbuild nulla"], + "assets.deploy": [ + "tailwind nulla --minify", + "esbuild nulla --minify", + "phx.digest" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..3283437 --- /dev/null +++ b/mix.lock @@ -0,0 +1,41 @@ +%{ + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, + "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..1672d2d --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Nulla.Repo.insert!(%Nulla.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/nulla_web/controllers/error_html_test.exs b/test/nulla_web/controllers/error_html_test.exs new file mode 100644 index 0000000..4406660 --- /dev/null +++ b/test/nulla_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule NullaWeb.ErrorHTMLTest do + use NullaWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(NullaWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(NullaWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/nulla_web/controllers/error_json_test.exs b/test/nulla_web/controllers/error_json_test.exs new file mode 100644 index 0000000..0734e4d --- /dev/null +++ b/test/nulla_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule NullaWeb.ErrorJSONTest do + use NullaWeb.ConnCase, async: true + + test "renders 404" do + assert NullaWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert NullaWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/nulla_web/controllers/page_controller_test.exs b/test/nulla_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..5e8da73 --- /dev/null +++ b/test/nulla_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule NullaWeb.PageControllerTest do + use NullaWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..10ed035 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule NullaWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use NullaWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint NullaWeb.Endpoint + + use NullaWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import NullaWeb.ConnCase + end + end + + setup tags do + Nulla.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..c17e6b7 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule Nulla.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Nulla.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Nulla.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Nulla.DataCase + end + end + + setup tags do + Nulla.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Nulla.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..b0b52da --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Nulla.Repo, :manual) From 82f55f7afea2b9d3e8af3f7b0a7adcd894b47845 Mon Sep 17 00:00:00 2001 From: miraikumiko Date: Fri, 4 Jul 2025 10:25:40 +0200 Subject: [PATCH 144/144] Add all --- config/config.exs | 12 + config/test.exs | 3 + lib/nulla/accounts.ex | 377 +++++++++++++ lib/nulla/accounts/user.ex | 171 ++++++ lib/nulla/accounts/user_notifier.ex | 79 +++ lib/nulla/accounts/user_token.ex | 179 ++++++ lib/nulla/activities.ex | 104 ++++ lib/nulla/activities/activity.ex | 34 ++ lib/nulla/actors.ex | 104 ++++ lib/nulla/actors/actor.ex | 95 ++++ lib/nulla/application.ex | 5 +- lib/nulla/http_signature.ex | 111 ++++ lib/nulla/key_gen.ex | 24 + lib/nulla/media_attachments.ex | 104 ++++ .../media_attachments/media_attachment.ex | 35 ++ lib/nulla/notes.ex | 104 ++++ lib/nulla/notes/note.ex | 60 +++ lib/nulla/relations.ex | 104 ++++ lib/nulla/relations/relation.ex | 71 +++ lib/nulla/sender.ex | 24 + lib/nulla/snowflake.ex | 62 +++ lib/nulla/types/string_or_json.ex | 46 ++ .../components/layouts/root.html.heex | 41 ++ .../controllers/activity_controller.ex | 43 ++ lib/nulla_web/controllers/activity_json.ex | 41 ++ lib/nulla_web/controllers/actor_controller.ex | 43 ++ lib/nulla_web/controllers/actor_json.ex | 49 ++ lib/nulla_web/controllers/changeset_json.ex | 25 + .../controllers/fallback_controller.ex | 24 + .../media_attachment_controller.ex | 45 ++ .../controllers/media_attachment_json.ex | 29 + lib/nulla_web/controllers/note_controller.ex | 43 ++ lib/nulla_web/controllers/note_json.ex | 32 ++ .../controllers/relation_controller.ex | 43 ++ lib/nulla_web/controllers/relation_json.ex | 35 ++ .../controllers/user_session_controller.ex | 42 ++ .../user_confirmation_instructions_live.ex | 51 ++ lib/nulla_web/live/user_confirmation_live.ex | 58 ++ .../live/user_forgot_password_live.ex | 50 ++ lib/nulla_web/live/user_login_live.ex | 43 ++ lib/nulla_web/live/user_registration_live.ex | 87 +++ .../live/user_reset_password_live.ex | 89 +++ lib/nulla_web/live/user_settings_live.ex | 167 ++++++ lib/nulla_web/router.ex | 55 +- lib/nulla_web/user_auth.ex | 245 +++++++++ mix.exs | 1 + mix.lock | 3 + ...0250701093122_create_users_auth_tables.exs | 30 ++ .../20250701133126_create_actors.exs | 41 ++ .../20250702091405_create_notes.exs | 23 + ...0250702091750_create_media_attachments.exs | 18 + .../20250702151805_create_activities.exs | 21 + .../20250702152953_create_relations.exs | 30 ++ test/nulla/accounts_test.exs | 508 ++++++++++++++++++ test/nulla/activities_test.exs | 84 +++ test/nulla/actors_test.exs | 210 ++++++++ test/nulla/media_attachments_test.exs | 113 ++++ test/nulla/notes_test.exs | 115 ++++ test/nulla/relations_test.exs | 137 +++++ .../controllers/activity_controller_test.exs | 107 ++++ .../controllers/actor_controller_test.exs | 211 ++++++++ .../media_attachment_controller_test.exs | 118 ++++ .../controllers/note_controller_test.exs | 131 +++++ .../controllers/relation_controller_test.exs | 154 ++++++ .../user_session_controller_test.exs | 113 ++++ ...er_confirmation_instructions_live_test.exs | 67 +++ .../live/user_confirmation_live_test.exs | 89 +++ .../live/user_forgot_password_live_test.exs | 63 +++ test/nulla_web/live/user_login_live_test.exs | 87 +++ .../live/user_registration_live_test.exs | 87 +++ .../live/user_reset_password_live_test.exs | 118 ++++ .../live/user_settings_live_test.exs | 210 ++++++++ test/nulla_web/user_auth_test.exs | 272 ++++++++++ test/support/conn_case.ex | 26 + test/support/fixtures/accounts_fixtures.ex | 31 ++ test/support/fixtures/activities_fixtures.ex | 25 + test/support/fixtures/actors_fixtures.ex | 60 +++ .../fixtures/media_attachments_fixtures.ex | 30 ++ test/support/fixtures/notes_fixtures.ex | 33 ++ test/support/fixtures/relations_fixtures.ex | 38 ++ 80 files changed, 6687 insertions(+), 5 deletions(-) create mode 100644 lib/nulla/accounts.ex create mode 100644 lib/nulla/accounts/user.ex create mode 100644 lib/nulla/accounts/user_notifier.ex create mode 100644 lib/nulla/accounts/user_token.ex create mode 100644 lib/nulla/activities.ex create mode 100644 lib/nulla/activities/activity.ex create mode 100644 lib/nulla/actors.ex create mode 100644 lib/nulla/actors/actor.ex create mode 100644 lib/nulla/http_signature.ex create mode 100644 lib/nulla/key_gen.ex create mode 100644 lib/nulla/media_attachments.ex create mode 100644 lib/nulla/media_attachments/media_attachment.ex create mode 100644 lib/nulla/notes.ex create mode 100644 lib/nulla/notes/note.ex create mode 100644 lib/nulla/relations.ex create mode 100644 lib/nulla/relations/relation.ex create mode 100644 lib/nulla/sender.ex create mode 100644 lib/nulla/snowflake.ex create mode 100644 lib/nulla/types/string_or_json.ex create mode 100644 lib/nulla_web/controllers/activity_controller.ex create mode 100644 lib/nulla_web/controllers/activity_json.ex create mode 100644 lib/nulla_web/controllers/actor_controller.ex create mode 100644 lib/nulla_web/controllers/actor_json.ex create mode 100644 lib/nulla_web/controllers/changeset_json.ex create mode 100644 lib/nulla_web/controllers/fallback_controller.ex create mode 100644 lib/nulla_web/controllers/media_attachment_controller.ex create mode 100644 lib/nulla_web/controllers/media_attachment_json.ex create mode 100644 lib/nulla_web/controllers/note_controller.ex create mode 100644 lib/nulla_web/controllers/note_json.ex create mode 100644 lib/nulla_web/controllers/relation_controller.ex create mode 100644 lib/nulla_web/controllers/relation_json.ex create mode 100644 lib/nulla_web/controllers/user_session_controller.ex create mode 100644 lib/nulla_web/live/user_confirmation_instructions_live.ex create mode 100644 lib/nulla_web/live/user_confirmation_live.ex create mode 100644 lib/nulla_web/live/user_forgot_password_live.ex create mode 100644 lib/nulla_web/live/user_login_live.ex create mode 100644 lib/nulla_web/live/user_registration_live.ex create mode 100644 lib/nulla_web/live/user_reset_password_live.ex create mode 100644 lib/nulla_web/live/user_settings_live.ex create mode 100644 lib/nulla_web/user_auth.ex create mode 100644 priv/repo/migrations/20250701093122_create_users_auth_tables.exs create mode 100644 priv/repo/migrations/20250701133126_create_actors.exs create mode 100644 priv/repo/migrations/20250702091405_create_notes.exs create mode 100644 priv/repo/migrations/20250702091750_create_media_attachments.exs create mode 100644 priv/repo/migrations/20250702151805_create_activities.exs create mode 100644 priv/repo/migrations/20250702152953_create_relations.exs create mode 100644 test/nulla/accounts_test.exs create mode 100644 test/nulla/activities_test.exs create mode 100644 test/nulla/actors_test.exs create mode 100644 test/nulla/media_attachments_test.exs create mode 100644 test/nulla/notes_test.exs create mode 100644 test/nulla/relations_test.exs create mode 100644 test/nulla_web/controllers/activity_controller_test.exs create mode 100644 test/nulla_web/controllers/actor_controller_test.exs create mode 100644 test/nulla_web/controllers/media_attachment_controller_test.exs create mode 100644 test/nulla_web/controllers/note_controller_test.exs create mode 100644 test/nulla_web/controllers/relation_controller_test.exs create mode 100644 test/nulla_web/controllers/user_session_controller_test.exs create mode 100644 test/nulla_web/live/user_confirmation_instructions_live_test.exs create mode 100644 test/nulla_web/live/user_confirmation_live_test.exs create mode 100644 test/nulla_web/live/user_forgot_password_live_test.exs create mode 100644 test/nulla_web/live/user_login_live_test.exs create mode 100644 test/nulla_web/live/user_registration_live_test.exs create mode 100644 test/nulla_web/live/user_reset_password_live_test.exs create mode 100644 test/nulla_web/live/user_settings_live_test.exs create mode 100644 test/nulla_web/user_auth_test.exs create mode 100644 test/support/fixtures/accounts_fixtures.ex create mode 100644 test/support/fixtures/activities_fixtures.ex create mode 100644 test/support/fixtures/actors_fixtures.ex create mode 100644 test/support/fixtures/media_attachments_fixtures.ex create mode 100644 test/support/fixtures/notes_fixtures.ex create mode 100644 test/support/fixtures/relations_fixtures.ex diff --git a/config/config.exs b/config/config.exs index b8cf59b..8ab193c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,6 +22,18 @@ config :nulla, NullaWeb.Endpoint, pubsub_server: Nulla.PubSub, live_view: [signing_salt: "rmaJ4fGm"] +# Snowflake configuration +config :nulla, :snowflake, worker_id: 1 + +# Instance configuration +config :nulla, :instance, + name: "Nulla", + description: "Freedom Social Network", + registration: false, + max_characters: 5000, + max_upload_size: 50, + api_limit: 100 + # Configures the mailer # # By default it uses the "Local" adapter which stores the emails diff --git a/config/test.exs b/config/test.exs index c0f45e2..8c70f7f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/lib/nulla/accounts.ex b/lib/nulla/accounts.ex new file mode 100644 index 0000000..b6e5d8b --- /dev/null +++ b/lib/nulla/accounts.ex @@ -0,0 +1,377 @@ +defmodule Nulla.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Creates a new api token for a user. + + The token returned must be saved somewhere safe. + This token cannot be recovered from the database. + """ + def create_user_api_token(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") + Repo.insert!(user_token) + encoded_token + end + + @doc """ + Fetches the user by API token. + """ + def fetch_user_by_api_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "api-token"), + %User{} = user <- Repo.one(query) do + {:ok, user} + else + _ -> :error + end + end +end diff --git a/lib/nulla/accounts/user.ex b/lib/nulla/accounts/user.ex new file mode 100644 index 0000000..cace737 --- /dev/null +++ b/lib/nulla/accounts/user.ex @@ -0,0 +1,171 @@ +defmodule Nulla.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> maybe_put_id() + |> validate_email(opts) + |> validate_password(opts) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, Nulla.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Nulla.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/nulla/accounts/user_notifier.ex b/lib/nulla/accounts/user_notifier.ex new file mode 100644 index 0000000..682f899 --- /dev/null +++ b/lib/nulla/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Nulla.Accounts.UserNotifier do + import Swoosh.Email + + alias Nulla.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Nulla", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/nulla/accounts/user_token.ex b/lib/nulla/accounts/user_token.ex new file mode 100644 index 0000000..d351435 --- /dev/null +++ b/lib/nulla/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule Nulla.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Nulla.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Nulla.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in by_token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/nulla/activities.ex b/lib/nulla/activities.ex new file mode 100644 index 0000000..c6119fd --- /dev/null +++ b/lib/nulla/activities.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Activities do + @moduledoc """ + The Activities context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Activities.Activity + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + Repo.all(Activity) + end + + @doc """ + Gets a single activity. + + Raises `Ecto.NoResultsError` if the Activity does not exist. + + ## Examples + + iex> get_activity!(123) + %Activity{} + + iex> get_activity!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity!(id), do: Repo.get!(Activity, id) + + @doc """ + Creates a activity. + + ## Examples + + iex> create_activity(%{field: value}) + {:ok, %Activity{}} + + iex> create_activity(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity(attrs \\ %{}) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity. + + ## Examples + + iex> update_activity(activity, %{field: new_value}) + {:ok, %Activity{}} + + iex> update_activity(activity, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity(%Activity{} = activity, attrs) do + activity + |> Activity.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity. + + ## Examples + + iex> delete_activity(activity) + {:ok, %Activity{}} + + iex> delete_activity(activity) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity(%Activity{} = activity) do + Repo.delete(activity) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity changes. + + ## Examples + + iex> change_activity(activity) + %Ecto.Changeset{data: %Activity{}} + + """ + def change_activity(%Activity{} = activity, attrs \\ %{}) do + Activity.changeset(activity, attrs) + end +end diff --git a/lib/nulla/activities/activity.ex b/lib/nulla/activities/activity.ex new file mode 100644 index 0000000..23a35b7 --- /dev/null +++ b/lib/nulla/activities/activity.ex @@ -0,0 +1,34 @@ +defmodule Nulla.Activities.Activity do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Types.StringOrJson + + @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} + @primary_key {:id, :integer, autogenerate: false} + schema "activities" do + field :ap_id, :string + field :type, :string + field :actor, :string + field :object, StringOrJson + field :to, {:array, :string} + field :cc, {:array, :string} + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:ap_id, :type, :actor, :object, :to, :cc]) + |> maybe_put_id() + |> validate_required([:ap_id, :type, :actor, :object]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/actors.ex b/lib/nulla/actors.ex new file mode 100644 index 0000000..9064c46 --- /dev/null +++ b/lib/nulla/actors.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Actors do + @moduledoc """ + The Actors context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Actors.Actor + + @doc """ + Returns the list of actors. + + ## Examples + + iex> list_actors() + [%Actor{}, ...] + + """ + def list_actors do + Repo.all(Actor) + end + + @doc """ + Gets a single actor. + + Raises `Ecto.NoResultsError` if the Actor does not exist. + + ## Examples + + iex> get_actor!(123) + %Actor{} + + iex> get_actor!(456) + ** (Ecto.NoResultsError) + + """ + def get_actor!(id), do: Repo.get!(Actor, id) + + @doc """ + Creates a actor. + + ## Examples + + iex> create_actor(%{field: value}) + {:ok, %Actor{}} + + iex> create_actor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_actor(attrs \\ %{}) do + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a actor. + + ## Examples + + iex> update_actor(actor, %{field: new_value}) + {:ok, %Actor{}} + + iex> update_actor(actor, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_actor(%Actor{} = actor, attrs) do + actor + |> Actor.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a actor. + + ## Examples + + iex> delete_actor(actor) + {:ok, %Actor{}} + + iex> delete_actor(actor) + {:error, %Ecto.Changeset{}} + + """ + def delete_actor(%Actor{} = actor) do + Repo.delete(actor) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking actor changes. + + ## Examples + + iex> change_actor(actor) + %Ecto.Changeset{data: %Actor{}} + + """ + def change_actor(%Actor{} = actor, attrs \\ %{}) do + Actor.changeset(actor, attrs) + end +end diff --git a/lib/nulla/actors/actor.ex b/lib/nulla/actors/actor.ex new file mode 100644 index 0000000..67a71d1 --- /dev/null +++ b/lib/nulla/actors/actor.ex @@ -0,0 +1,95 @@ +defmodule Nulla.Actors.Actor do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "actors" do + field :acct, :string + field :ap_id, :string + field :type, :string + field :following, :string + field :followers, :string + field :inbox, :string + field :outbox, :string + field :featured, :string + field :featuredTags, :string + field :preferredUsername, :string + field :name, :string + field :summary, :string + field :url, :string + field :manuallyApprovesFollowers, :boolean, default: false + field :discoverable, :boolean, default: true + field :indexable, :boolean, default: true + field :published, :utc_datetime + field :memorial, :boolean, default: false + field :publicKey, :map + field :privateKeyPem, :string + field :tag, {:array, :map}, default: [] + field :attachment, {:array, :map}, default: [] + field :endpoints, :map + field :icon, :map + field :image, :map + field :vcard_bday, :date + field :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :privateKeyPem, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + |> maybe_put_id() + |> validate_required([ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :url, + :publicKey, + :endpoints + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex index 7e86858..6832652 100644 --- a/lib/nulla/application.ex +++ b/lib/nulla/application.ex @@ -7,6 +7,8 @@ defmodule Nulla.Application do @impl true def start(_type, _args) do + worker_id = Application.fetch_env!(:nulla, :snowflake)[:worker_id] + children = [ NullaWeb.Telemetry, Nulla.Repo, @@ -17,7 +19,8 @@ defmodule Nulla.Application do # Start a worker by calling: Nulla.Worker.start_link(arg) # {Nulla.Worker, arg}, # Start to serve requests, typically the last entry - NullaWeb.Endpoint + NullaWeb.Endpoint, + {Nulla.Snowflake, worker_id} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex new file mode 100644 index 0000000..35c3977 --- /dev/null +++ b/lib/nulla/http_signature.ex @@ -0,0 +1,111 @@ +defmodule Nulla.HTTPSignature do + import Plug.Conn + + def make_headers(body, inbox_url, publicKeyId, privateKeyPem) do + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + uri = URI.parse(inbox_url) + + signature_string = + "(request-target): post #{uri.path}\n" <> + "host: #{uri.host}\n" <> + "date: #{date}\n" <> + "digest: #{digest}" + + private_key = + case :public_key.pem_decode(privateKeyPem) do + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{publicKeyId}", + algorithm="rsa-sha256", + headers="(request-target) host date digest", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"Digest", digest}, + {"Signature", signature_header} + ] + end + + def verify(conn, public_key_pem) do + with [sig_header] <- get_req_header(conn, "signature"), + signature_map <- parse_signature_header(sig_header), + {:ok, signed_string} <- build_signature_string(signature_map["headers"], conn), + true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do + :ok + else + _ -> {:error, :invalid_signature} + end + end + + defp parse_signature_header(header) do + header = + header + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {String.trim(k), String.trim(v, ~s("))} + end) + |> Enum.into(%{}) + + header + end + + defp build_signature_string(nil, _conn), do: {:error, :missing_headers} + + defp build_signature_string(headers_str, conn) do + headers = String.split(headers_str, " ") + + result = + Enum.map(headers, fn header -> + line = + case header do + "(request-target)" -> + method = String.downcase(conn.method) + + path = + conn.request_path <> + if conn.query_string != "", do: "?" <> conn.query_string, else: "" + + "(request-target): #{method} #{path}" + + "host" -> + "host: #{conn.host}" + + _ -> + value = get_req_header(conn, header) |> List.first() + if value, do: "#{header}: #{value}", else: nil + end + + line + end) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + {:ok, result} + end + + defp verify_signature(public_key_pem, signed_string, signature_base64) do + public_key = + :public_key.pem_decode(public_key_pem) + |> hd() + |> :public_key.pem_entry_decode() + + signature = Base.decode64!(signature_base64) + + :public_key.verify(signed_string, :sha256, signature, public_key) + end +end diff --git a/lib/nulla/key_gen.ex b/lib/nulla/key_gen.ex new file mode 100644 index 0000000..8a75cb5 --- /dev/null +++ b/lib/nulla/key_gen.ex @@ -0,0 +1,24 @@ +defmodule Nulla.KeyGen do + def gen do + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + + private_pem = + :public_key.pem_encode([entry]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + [private_key_code] = :public_key.pem_decode(private_pem) + private_key = :public_key.pem_entry_decode(private_key_code) + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + + public_pem = + :public_key.pem_encode([public_key]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + {public_pem, private_pem} + end +end diff --git a/lib/nulla/media_attachments.ex b/lib/nulla/media_attachments.ex new file mode 100644 index 0000000..334319b --- /dev/null +++ b/lib/nulla/media_attachments.ex @@ -0,0 +1,104 @@ +defmodule Nulla.MediaAttachments do + @moduledoc """ + The MediaAttachments context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Returns the list of media_attachments. + + ## Examples + + iex> list_media_attachments() + [%MediaAttachment{}, ...] + + """ + def list_media_attachments do + Repo.all(MediaAttachment) + end + + @doc """ + Gets a single media_attachment. + + Raises `Ecto.NoResultsError` if the Media attachment does not exist. + + ## Examples + + iex> get_media_attachment!(123) + %MediaAttachment{} + + iex> get_media_attachment!(456) + ** (Ecto.NoResultsError) + + """ + def get_media_attachment!(id), do: Repo.get!(MediaAttachment, id) + + @doc """ + Creates a media_attachment. + + ## Examples + + iex> create_media_attachment(%{field: value}) + {:ok, %MediaAttachment{}} + + iex> create_media_attachment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_media_attachment(attrs \\ %{}) do + %MediaAttachment{} + |> MediaAttachment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a media_attachment. + + ## Examples + + iex> update_media_attachment(media_attachment, %{field: new_value}) + {:ok, %MediaAttachment{}} + + iex> update_media_attachment(media_attachment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_media_attachment(%MediaAttachment{} = media_attachment, attrs) do + media_attachment + |> MediaAttachment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a media_attachment. + + ## Examples + + iex> delete_media_attachment(media_attachment) + {:ok, %MediaAttachment{}} + + iex> delete_media_attachment(media_attachment) + {:error, %Ecto.Changeset{}} + + """ + def delete_media_attachment(%MediaAttachment{} = media_attachment) do + Repo.delete(media_attachment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking media_attachment changes. + + ## Examples + + iex> change_media_attachment(media_attachment) + %Ecto.Changeset{data: %MediaAttachment{}} + + """ + def change_media_attachment(%MediaAttachment{} = media_attachment, attrs \\ %{}) do + MediaAttachment.changeset(media_attachment, attrs) + end +end diff --git a/lib/nulla/media_attachments/media_attachment.ex b/lib/nulla/media_attachments/media_attachment.ex new file mode 100644 index 0000000..6efd9e1 --- /dev/null +++ b/lib/nulla/media_attachments/media_attachment.ex @@ -0,0 +1,35 @@ +defmodule Nulla.MediaAttachments.MediaAttachment do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Notes.Note + + @primary_key {:id, :integer, autogenerate: false} + schema "media_attachments" do + field :type, :string + field :mediaType, :string + field :url, :string + field :name, :string + field :width, :integer + field :height, :integer + + belongs_to :note, Note + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(media_attachment, attrs) do + media_attachment + |> cast(attrs, [:type, :mediaType, :url, :name, :width, :height, :note_id]) + |> maybe_put_id() + |> validate_required([:type, :mediaType, :url, :note_id]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/notes.ex b/lib/nulla/notes.ex new file mode 100644 index 0000000..99c4999 --- /dev/null +++ b/lib/nulla/notes.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Notes do + @moduledoc """ + The Notes context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Notes.Note + + @doc """ + Returns the list of notes. + + ## Examples + + iex> list_notes() + [%Note{}, ...] + + """ + def list_notes do + Repo.all(Note) + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + + ## Examples + + iex> create_note(%{field: value}) + {:ok, %Note{}} + + iex> create_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_note(attrs \\ %{}) do + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a note. + + ## Examples + + iex> update_note(note, %{field: new_value}) + {:ok, %Note{}} + + iex> update_note(note, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_note(%Note{} = note, attrs) do + note + |> Note.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a note. + + ## Examples + + iex> delete_note(note) + {:ok, %Note{}} + + iex> delete_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_note(%Note{} = note) do + Repo.delete(note) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking note changes. + + ## Examples + + iex> change_note(note) + %Ecto.Changeset{data: %Note{}} + + """ + def change_note(%Note{} = note, attrs \\ %{}) do + Note.changeset(note, attrs) + end +end diff --git a/lib/nulla/notes/note.ex b/lib/nulla/notes/note.ex new file mode 100644 index 0000000..d4ba8b1 --- /dev/null +++ b/lib/nulla/notes/note.ex @@ -0,0 +1,60 @@ +defmodule Nulla.Notes.Note do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + alias Nulla.MediaAttachments.MediaAttachment + + @primary_key {:id, :integer, autogenerate: false} + schema "notes" do + field :inReplyTo, :string + field :published, :utc_datetime + field :url, :string + field :visibility, :string + field :to, {:array, :string} + field :cc, {:array, :string} + field :sensitive, :boolean, default: false + field :content, :string + field :language, :string + + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [ + :inReplyTo, + :published, + :url, + :visibility, + :to, + :cc, + :sensitive, + :content, + :language, + :actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :published, + :url, + :visibility, + :to, + :cc, + :content, + :language, + :actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/relations.ex b/lib/nulla/relations.ex new file mode 100644 index 0000000..088d01a --- /dev/null +++ b/lib/nulla/relations.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Relations do + @moduledoc """ + The Relations context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Relations.Relation + + @doc """ + Returns the list of relations. + + ## Examples + + iex> list_relations() + [%Relation{}, ...] + + """ + def list_relations do + Repo.all(Relation) + end + + @doc """ + Gets a single relation. + + Raises `Ecto.NoResultsError` if the Relation does not exist. + + ## Examples + + iex> get_relation!(123) + %Relation{} + + iex> get_relation!(456) + ** (Ecto.NoResultsError) + + """ + def get_relation!(id), do: Repo.get!(Relation, id) + + @doc """ + Creates a relation. + + ## Examples + + iex> create_relation(%{field: value}) + {:ok, %Relation{}} + + iex> create_relation(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_relation(attrs \\ %{}) do + %Relation{} + |> Relation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a relation. + + ## Examples + + iex> update_relation(relation, %{field: new_value}) + {:ok, %Relation{}} + + iex> update_relation(relation, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_relation(%Relation{} = relation, attrs) do + relation + |> Relation.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a relation. + + ## Examples + + iex> delete_relation(relation) + {:ok, %Relation{}} + + iex> delete_relation(relation) + {:error, %Ecto.Changeset{}} + + """ + def delete_relation(%Relation{} = relation) do + Repo.delete(relation) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking relation changes. + + ## Examples + + iex> change_relation(relation) + %Ecto.Changeset{data: %Relation{}} + + """ + def change_relation(%Relation{} = relation, attrs \\ %{}) do + Relation.changeset(relation, attrs) + end +end diff --git a/lib/nulla/relations/relation.ex b/lib/nulla/relations/relation.ex new file mode 100644 index 0000000..8ac7e38 --- /dev/null +++ b/lib/nulla/relations/relation.ex @@ -0,0 +1,71 @@ +defmodule Nulla.Relations.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + + schema "relations" do + field :following, :boolean, default: false + field :followed_by, :boolean, default: false + field :showing_replies, :boolean, default: true + field :showings_reblogs, :boolean, default: true + field :notifying, :boolean, default: false + field :muting, :boolean, default: false + field :muting_notifications, :boolean, default: false + field :blocking, :boolean, default: false + field :blocked_by, :boolean, default: false + field :domain_blocking, :boolean, default: false + field :requested, :boolean, default: false + field :note, :string + + belongs_to :local_actor, Actor + belongs_to :remote_actor, Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(relation, attrs) do + relation + |> cast(attrs, [ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex new file mode 100644 index 0000000..110fa2c --- /dev/null +++ b/lib/nulla/sender.ex @@ -0,0 +1,24 @@ +defmodule Nulla.Sender do + alias Nulla.HTTPSignature + alias NullaWeb.ActivityJSON + + def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do + body = Jason.encode!(ActivityJSON.activitypub(activity)) + headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) + request = Finch.build(method, inbox, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Activity #{activity.id} delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver activity #{activity.id}") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Activity #{activity.id} delivery failed") + {:error, reason} + end + end +end diff --git a/lib/nulla/snowflake.ex b/lib/nulla/snowflake.ex new file mode 100644 index 0000000..5b7c88e --- /dev/null +++ b/lib/nulla/snowflake.ex @@ -0,0 +1,62 @@ +defmodule Nulla.Snowflake do + use GenServer + import Bitwise + + @epoch :calendar.datetime_to_gregorian_seconds({{2020, 1, 1}, {0, 0, 0}}) * 1000 + @max_sequence 4095 + @time_shift 22 + @worker_shift 12 + + def start_link(worker_id) when worker_id in 0..1023 do + GenServer.start_link(__MODULE__, worker_id, name: __MODULE__) + end + + def next_id do + GenServer.call(__MODULE__, :next_id) + end + + @impl true + def init(worker_id) do + {:ok, %{last_timestamp: -1, sequence: 0, worker_id: worker_id}} + end + + @impl true + def handle_call(:next_id, _from, %{worker_id: worker_id} = state) do + timestamp = current_time() + + {timestamp, sequence, state} = + cond do + timestamp < state.last_timestamp -> + raise "Clock moved backwards" + + timestamp == state.last_timestamp and state.sequence < @max_sequence -> + {timestamp, state.sequence + 1, %{state | sequence: state.sequence + 1}} + + timestamp == state.last_timestamp -> + wait_for_next_millisecond(timestamp) + new_timestamp = current_time() + {new_timestamp, 0, %{state | last_timestamp: new_timestamp, sequence: 0}} + + true -> + {timestamp, 0, %{state | last_timestamp: timestamp, sequence: 0}} + end + + raw_id = + ((timestamp - @epoch) <<< @time_shift) + |> bor(worker_id <<< @worker_shift) + |> bor(sequence) + + id = Bitwise.band(raw_id, 0x7FFFFFFFFFFFFFFF) + + {:reply, id, %{state | last_timestamp: timestamp, sequence: sequence}} + end + + defp current_time do + System.system_time(:millisecond) + end + + defp wait_for_next_millisecond(last_ts) do + :timer.sleep(1) + if current_time() <= last_ts, do: wait_for_next_millisecond(last_ts), else: :ok + end +end diff --git a/lib/nulla/types/string_or_json.ex b/lib/nulla/types/string_or_json.ex new file mode 100644 index 0000000..4463c32 --- /dev/null +++ b/lib/nulla/types/string_or_json.ex @@ -0,0 +1,46 @@ +defmodule Nulla.Types.StringOrJson do + @behaviour Ecto.Type + + @impl true + def type, do: :string + + @impl true + def cast(value) when is_map(value) or is_list(value), do: {:ok, value} + + @impl true + def cast(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def cast(_), do: :error + + @impl true + def dump(value) when is_map(value) or is_list(value), do: Jason.encode(value) + + @impl true + def dump(value) when is_binary(value), do: {:ok, value} + + @impl true + def dump(_), do: :error + + @impl true + def load(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def load(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2), do: term1 == term2 +end diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex index ea6a36d..8ca2517 100644 --- a/lib/nulla_web/components/layouts/root.html.heex +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@ +
    + <%= if @current_user do %> +
  • + {@current_user.email} +
  • +
  • + <.link + href={~p"/users/settings"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Settings + +
  • +
  • + <.link + href={~p"/users/log_out"} + method="delete" + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log out + +
  • + <% else %> +
  • + <.link + href={~p"/users/register"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Register + +
  • +
  • + <.link + href={~p"/users/log_in"} + class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" + > + Log in + +
  • + <% end %> +
{@inner_content} diff --git a/lib/nulla_web/controllers/activity_controller.ex b/lib/nulla_web/controllers/activity_controller.ex new file mode 100644 index 0000000..9f25573 --- /dev/null +++ b/lib/nulla_web/controllers/activity_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActivityController do + use NullaWeb, :controller + + alias Nulla.Activities + alias Nulla.Activities.Activity + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + activities = Activities.list_activities() + render(conn, :index, activities: activities) + end + + def create(conn, %{"activity" => activity_params}) do + with {:ok, %Activity{} = activity} <- Activities.create_activity(activity_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/activities/#{activity}") + |> render(:show, activity: activity) + end + end + + def show(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + render(conn, :show, activity: activity) + end + + def update(conn, %{"id" => id, "activity" => activity_params}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{} = activity} <- Activities.update_activity(activity, activity_params) do + render(conn, :show, activity: activity) + end + end + + def delete(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{}} <- Activities.delete_activity(activity) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/activity_json.ex b/lib/nulla_web/controllers/activity_json.ex new file mode 100644 index 0000000..4713bed --- /dev/null +++ b/lib/nulla_web/controllers/activity_json.ex @@ -0,0 +1,41 @@ +defmodule NullaWeb.ActivityJSON do + alias Nulla.Activities.Activity + + @doc """ + Renders a list of activities. + """ + def index(%{activities: activities}) do + %{data: for(activity <- activities, do: data(activity))} + end + + @doc """ + Renders a single activity. + """ + def show(%{activity: activity}) do + %{data: data(activity)} + end + + defp data(%Activity{} = activity) do + %{ + id: activity.id, + ap_id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + } + end + + def activitypub(%Activity{} = activity) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + ) + end +end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex new file mode 100644 index 0000000..cf9028e --- /dev/null +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActorController do + use NullaWeb, :controller + + alias Nulla.Actors + alias Nulla.Actors.Actor + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + actors = Actors.list_actors() + render(conn, :index, actors: actors) + end + + def create(conn, %{"actor" => actor_params}) do + with {:ok, %Actor{} = actor} <- Actors.create_actor(actor_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/actors/#{actor}") + |> render(:show, actor: actor) + end + end + + def show(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + render(conn, :show, actor: actor) + end + + def update(conn, %{"id" => id, "actor" => actor_params}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{} = actor} <- Actors.update_actor(actor, actor_params) do + render(conn, :show, actor: actor) + end + end + + def delete(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{}} <- Actors.delete_actor(actor) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/actor_json.ex b/lib/nulla_web/controllers/actor_json.ex new file mode 100644 index 0000000..ca59e62 --- /dev/null +++ b/lib/nulla_web/controllers/actor_json.ex @@ -0,0 +1,49 @@ +defmodule NullaWeb.ActorJSON do + alias Nulla.Actors.Actor + + @doc """ + Renders a list of actors. + """ + def index(%{actors: actors}) do + %{data: for(actor <- actors, do: data(actor))} + end + + @doc """ + Renders a single actor. + """ + def show(%{actor: actor}) do + %{data: data(actor)} + end + + defp data(%Actor{} = actor) do + %{ + id: actor.id, + acct: actor.acct, + ap_id: actor.ap_id, + type: actor.type, + following: actor.following, + followers: actor.followers, + inbox: actor.inbox, + outbox: actor.outbox, + featured: actor.featured, + featuredTags: actor.featuredTags, + preferredUsername: actor.preferredUsername, + name: actor.name, + summary: actor.summary, + url: actor.url, + manuallyApprovesFollowers: actor.manuallyApprovesFollowers, + discoverable: actor.discoverable, + indexable: actor.indexable, + published: actor.published, + memorial: actor.memorial, + publicKey: actor.publicKey, + tag: actor.tag, + attachment: actor.attachment, + endpoints: actor.endpoints, + icon: actor.icon, + image: actor.image, + vcard_bday: actor.vcard_bday, + vcard_Address: actor.vcard_Address + } + end +end diff --git a/lib/nulla_web/controllers/changeset_json.ex b/lib/nulla_web/controllers/changeset_json.ex new file mode 100644 index 0000000..9eca0fd --- /dev/null +++ b/lib/nulla_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/nulla_web/controllers/fallback_controller.ex b/lib/nulla_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..9d731e1 --- /dev/null +++ b/lib/nulla_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule NullaWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use NullaWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: NullaWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/nulla_web/controllers/media_attachment_controller.ex b/lib/nulla_web/controllers/media_attachment_controller.ex new file mode 100644 index 0000000..840f503 --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_controller.ex @@ -0,0 +1,45 @@ +defmodule NullaWeb.MediaAttachmentController do + use NullaWeb, :controller + + alias Nulla.MediaAttachments + alias Nulla.MediaAttachments.MediaAttachment + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + media_attachments = MediaAttachments.list_media_attachments() + render(conn, :index, media_attachments: media_attachments) + end + + def create(conn, %{"media_attachment" => media_attachment_params}) do + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.create_media_attachment(media_attachment_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/media_attachments/#{media_attachment}") + |> render(:show, media_attachment: media_attachment) + end + end + + def show(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + render(conn, :show, media_attachment: media_attachment) + end + + def update(conn, %{"id" => id, "media_attachment" => media_attachment_params}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.update_media_attachment(media_attachment, media_attachment_params) do + render(conn, :show, media_attachment: media_attachment) + end + end + + def delete(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{}} <- MediaAttachments.delete_media_attachment(media_attachment) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/media_attachment_json.ex b/lib/nulla_web/controllers/media_attachment_json.ex new file mode 100644 index 0000000..8c24f1f --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_json.ex @@ -0,0 +1,29 @@ +defmodule NullaWeb.MediaAttachmentJSON do + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Renders a list of media_attachments. + """ + def index(%{media_attachments: media_attachments}) do + %{data: for(media_attachment <- media_attachments, do: data(media_attachment))} + end + + @doc """ + Renders a single media_attachment. + """ + def show(%{media_attachment: media_attachment}) do + %{data: data(media_attachment)} + end + + defp data(%MediaAttachment{} = media_attachment) do + %{ + id: media_attachment.id, + type: media_attachment.type, + mediaType: media_attachment.mediaType, + url: media_attachment.url, + name: media_attachment.name, + width: media_attachment.width, + height: media_attachment.height + } + end +end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex new file mode 100644 index 0000000..925ebe3 --- /dev/null +++ b/lib/nulla_web/controllers/note_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.NoteController do + use NullaWeb, :controller + + alias Nulla.Notes + alias Nulla.Notes.Note + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + notes = Notes.list_notes() + render(conn, :index, notes: notes) + end + + def create(conn, %{"note" => note_params}) do + with {:ok, %Note{} = note} <- Notes.create_note(note_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/notes/#{note}") + |> render(:show, note: note) + end + end + + def show(conn, %{"id" => id}) do + note = Notes.get_note!(id) + render(conn, :show, note: note) + end + + def update(conn, %{"id" => id, "note" => note_params}) do + note = Notes.get_note!(id) + + with {:ok, %Note{} = note} <- Notes.update_note(note, note_params) do + render(conn, :show, note: note) + end + end + + def delete(conn, %{"id" => id}) do + note = Notes.get_note!(id) + + with {:ok, %Note{}} <- Notes.delete_note(note) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/note_json.ex b/lib/nulla_web/controllers/note_json.ex new file mode 100644 index 0000000..a7773d9 --- /dev/null +++ b/lib/nulla_web/controllers/note_json.ex @@ -0,0 +1,32 @@ +defmodule NullaWeb.NoteJSON do + alias Nulla.Notes.Note + + @doc """ + Renders a list of notes. + """ + def index(%{notes: notes}) do + %{data: for(note <- notes, do: data(note))} + end + + @doc """ + Renders a single note. + """ + def show(%{note: note}) do + %{data: data(note)} + end + + defp data(%Note{} = note) do + %{ + id: note.id, + inReplyTo: note.inReplyTo, + published: note.published, + url: note.url, + visibility: note.visibility, + to: note.to, + cc: note.cc, + sensitive: note.sensitive, + content: note.content, + language: note.language + } + end +end diff --git a/lib/nulla_web/controllers/relation_controller.ex b/lib/nulla_web/controllers/relation_controller.ex new file mode 100644 index 0000000..cc5f968 --- /dev/null +++ b/lib/nulla_web/controllers/relation_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.RelationController do + use NullaWeb, :controller + + alias Nulla.Relations + alias Nulla.Relations.Relation + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + relations = Relations.list_relations() + render(conn, :index, relations: relations) + end + + def create(conn, %{"relation" => relation_params}) do + with {:ok, %Relation{} = relation} <- Relations.create_relation(relation_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/relations/#{relation}") + |> render(:show, relation: relation) + end + end + + def show(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + render(conn, :show, relation: relation) + end + + def update(conn, %{"id" => id, "relation" => relation_params}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{} = relation} <- Relations.update_relation(relation, relation_params) do + render(conn, :show, relation: relation) + end + end + + def delete(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{}} <- Relations.delete_relation(relation) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/relation_json.ex b/lib/nulla_web/controllers/relation_json.ex new file mode 100644 index 0000000..42ab693 --- /dev/null +++ b/lib/nulla_web/controllers/relation_json.ex @@ -0,0 +1,35 @@ +defmodule NullaWeb.RelationJSON do + alias Nulla.Relations.Relation + + @doc """ + Renders a list of relations. + """ + def index(%{relations: relations}) do + %{data: for(relation <- relations, do: data(relation))} + end + + @doc """ + Renders a single relation. + """ + def show(%{relation: relation}) do + %{data: data(relation)} + end + + defp data(%Relation{} = relation) do + %{ + id: relation.id, + following: relation.following, + followed_by: relation.followed_by, + showing_replies: relation.showing_replies, + showings_reblogs: relation.showings_reblogs, + notifying: relation.notifying, + muting: relation.muting, + muting_notifications: relation.muting_notifications, + blocking: relation.blocking, + blocked_by: relation.blocked_by, + domain_blocking: relation.domain_blocking, + requested: relation.requested, + note: relation.note + } + end +end diff --git a/lib/nulla_web/controllers/user_session_controller.ex b/lib/nulla_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..ba818f6 --- /dev/null +++ b/lib/nulla_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule NullaWeb.UserSessionController do + use NullaWeb, :controller + + alias Nulla.Accounts + alias NullaWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/nulla_web/live/user_confirmation_instructions_live.ex b/lib/nulla_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..8de0f24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,51 @@ +defmodule NullaWeb.UserConfirmationInstructionsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_confirmation_live.ex b/lib/nulla_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..0aabe24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule NullaWeb.UserConfirmationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + form = to_form(%{"token" => token}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/lib/nulla_web/live/user_forgot_password_live.ex b/lib/nulla_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..21e3cfc --- /dev/null +++ b/lib/nulla_web/live/user_forgot_password_live.ex @@ -0,0 +1,50 @@ +defmodule NullaWeb.UserForgotPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_login_live.ex b/lib/nulla_web/live/user_login_live.ex new file mode 100644 index 0000000..a005b1d --- /dev/null +++ b/lib/nulla_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.UserLoginLive do + use NullaWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Log in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Logging in..." class="w-full"> + Log in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = Phoenix.Flash.get(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/lib/nulla_web/live/user_registration_live.ex b/lib/nulla_web/live/user_registration_live.ex new file mode 100644 index 0000000..e35ad1f --- /dev/null +++ b/lib/nulla_web/live/user_registration_live.ex @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + alias Nulla.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/nulla_web/live/user_reset_password_live.ex b/lib/nulla_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..04ddbc9 --- /dev/null +++ b/lib/nulla_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserResetPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/lib/nulla_web/live/user_settings_live.ex b/lib/nulla_web/live/user_settings_live.ex new file mode 100644 index 0000000..8932554 --- /dev/null +++ b/lib/nulla_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule NullaWeb.UserSettingsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 95706c9..0693036 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -1,6 +1,8 @@ defmodule NullaWeb.Router do use NullaWeb, :router + import NullaWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,10 +10,12 @@ defmodule NullaWeb.Router do plug :put_root_layout, html: {NullaWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do plug :accepts, ["json"] + plug :fetch_api_user end scope "/", NullaWeb do @@ -20,10 +24,15 @@ defmodule NullaWeb.Router do get "/", PageController, :home end - # Other scopes may use custom stacks. - # scope "/api", NullaWeb do - # pipe_through :api - # end + scope "/api", NullaWeb do + pipe_through :api + + resources "/actors", ActorController, except: [:new, :edit] + resources "/notes", NoteController, except: [:new, :edit] + resources "/media_attachments", MediaAttachmentController, except: [:new, :edit] + resources "/relations", RelationController, except: [:new, :edit] + resources "/activities", ActivityController, except: [:new, :edit] + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:nulla, :dev_routes) do @@ -41,4 +50,42 @@ defmodule NullaWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", NullaWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{NullaWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + + post "/users/log_in", UserSessionController, :create + end + + scope "/", NullaWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end + end + + scope "/", NullaWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + + live_session :current_user, + on_mount: [{NullaWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end + end end diff --git a/lib/nulla_web/user_auth.ex b/lib/nulla_web/user_auth.ex new file mode 100644 index 0000000..49913f7 --- /dev/null +++ b/lib/nulla_web/user_auth.ex @@ -0,0 +1,245 @@ +defmodule NullaWeb.UserAuth do + use NullaWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Nulla.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_nulla_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + NullaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule NullaWeb.PageLive do + use NullaWeb, :live_view + + on_mount {NullaWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + def fetch_api_user(conn, _opts) do + if Mix.env() != :test do + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, user} <- Accounts.fetch_user_by_api_token(token) do + assign(conn, :current_user, user) + else + _ -> + conn + |> send_resp(:unauthorized, "No access for you") + |> halt() + end + else + conn + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index 09ceeec..5343832 100644 --- a/mix.exs +++ b/mix.exs @@ -32,6 +32,7 @@ defmodule Nulla.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.21"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, diff --git a/mix.lock b/mix.lock index 3283437..a852d05 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,14 @@ %{ "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, diff --git a/priv/repo/migrations/20250701093122_create_users_auth_tables.exs b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs new file mode 100644 index 0000000..7068f76 --- /dev/null +++ b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users, primary_key: false) do + add :id, :bigint, primary_key: true + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20250701133126_create_actors.exs b/priv/repo/migrations/20250701133126_create_actors.exs new file mode 100644 index 0000000..4dfe0ea --- /dev/null +++ b/priv/repo/migrations/20250701133126_create_actors.exs @@ -0,0 +1,41 @@ +defmodule Nulla.Repo.Migrations.CreateActors do + use Ecto.Migration + + def change do + create table(:actors, primary_key: false) do + add :id, :bigint, primary_key: true + add :acct, :string + add :ap_id, :string + add :type, :string + add :following, :string + add :followers, :string + add :inbox, :string + add :outbox, :string + add :featured, :string + add :featuredTags, :string + add :preferredUsername, :string + add :name, :string + add :summary, :string + add :url, :string + add :manuallyApprovesFollowers, :boolean, default: false, null: false + add :discoverable, :boolean, default: true, null: false + add :indexable, :boolean, default: true, null: false + add :published, :utc_datetime + add :memorial, :boolean, default: false, null: false + add :publicKey, :map + add :privateKeyPem, :text + add :tag, {:array, :map}, default: [] + add :attachment, {:array, :map}, default: [] + add :endpoints, :map + add :icon, :map + add :image, :map + add :vcard_bday, :date + add :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:actors, [:acct]) + create unique_index(:actors, [:ap_id]) + end +end diff --git a/priv/repo/migrations/20250702091405_create_notes.exs b/priv/repo/migrations/20250702091405_create_notes.exs new file mode 100644 index 0000000..6f65471 --- /dev/null +++ b/priv/repo/migrations/20250702091405_create_notes.exs @@ -0,0 +1,23 @@ +defmodule Nulla.Repo.Migrations.CreateNotes do + use Ecto.Migration + + def change do + create table(:notes, primary_key: false) do + add :id, :bigint, primary_key: true + add :inReplyTo, :string + add :published, :utc_datetime + add :url, :string + add :visibility, :string + add :to, {:array, :string} + add :cc, {:array, :string} + add :sensitive, :boolean, default: false, null: false + add :content, :string + add :language, :string + add :actor_id, references(:actors, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:notes, [:actor_id]) + end +end diff --git a/priv/repo/migrations/20250702091750_create_media_attachments.exs b/priv/repo/migrations/20250702091750_create_media_attachments.exs new file mode 100644 index 0000000..44e69c6 --- /dev/null +++ b/priv/repo/migrations/20250702091750_create_media_attachments.exs @@ -0,0 +1,18 @@ +defmodule Nulla.Repo.Migrations.CreateMediaAttachments do + use Ecto.Migration + + def change do + create table(:media_attachments, primary_key: false) do + add :id, :bigint, primary_key: true + add :type, :string + add :mediaType, :string + add :url, :string + add :name, :string + add :width, :integer + add :height, :integer + add :note_id, references(:notes, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20250702151805_create_activities.exs b/priv/repo/migrations/20250702151805_create_activities.exs new file mode 100644 index 0000000..67c7253 --- /dev/null +++ b/priv/repo/migrations/20250702151805_create_activities.exs @@ -0,0 +1,21 @@ +defmodule Nulla.Repo.Migrations.CreateActivities do + use Ecto.Migration + + def change do + create table(:activities, primary_key: false) do + add :id, :bigint, primary_key: true + add :ap_id, :string + add :type, :string + add :actor, :string + add :object, :string + add :to, {:array, :string} + add :cc, {:array, :string} + + timestamps(type: :utc_datetime) + end + + create index(:activities, [:ap_id]) + create index(:activities, [:type]) + create index(:activities, [:actor]) + end +end diff --git a/priv/repo/migrations/20250702152953_create_relations.exs b/priv/repo/migrations/20250702152953_create_relations.exs new file mode 100644 index 0000000..a8d7caf --- /dev/null +++ b/priv/repo/migrations/20250702152953_create_relations.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateRelations do + use Ecto.Migration + + def change do + create table(:relations) do + add :following, :boolean, default: false, null: false + add :followed_by, :boolean, default: false, null: false + add :showing_replies, :boolean, default: false, null: false + add :showings_reblogs, :boolean, default: false, null: false + add :notifying, :boolean, default: false, null: false + add :muting, :boolean, default: false, null: false + add :muting_notifications, :boolean, default: false, null: false + add :blocking, :boolean, default: false, null: false + add :blocked_by, :boolean, default: false, null: false + add :domain_blocking, :boolean, default: false, null: false + add :requested, :boolean, default: false, null: false + add :note, :string + + add :local_actor_id, references(:actors, type: :bigint), null: false + add :remote_actor_id, references(:actors, type: :bigint), null: false + + timestamps(type: :utc_datetime) + end + + create index(:relations, [:local_actor_id]) + create index(:relations, [:remote_actor_id]) + + create unique_index(:relations, [:local_actor_id, :remote_actor_id]) + end +end diff --git a/test/nulla/accounts_test.exs b/test/nulla/accounts_test.exs new file mode 100644 index 0000000..a5c5724 --- /dev/null +++ b/test/nulla/accounts_test.exs @@ -0,0 +1,508 @@ +defmodule Nulla.AccountsTest do + use Nulla.DataCase + + alias Nulla.Accounts + + import Nulla.AccountsFixtures + alias Nulla.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/nulla/activities_test.exs b/test/nulla/activities_test.exs new file mode 100644 index 0000000..ffd8f08 --- /dev/null +++ b/test/nulla/activities_test.exs @@ -0,0 +1,84 @@ +defmodule Nulla.ActivitiesTest do + use Nulla.DataCase + + alias Nulla.Activities + + describe "activities" do + alias Nulla.Activities.Activity + + import Nulla.ActivitiesFixtures + + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + test "list_activities/0 returns all activities" do + activity = activity_fixture() + assert Activities.list_activities() == [activity] + end + + test "get_activity!/1 returns the activity with given id" do + activity = activity_fixture() + assert Activities.get_activity!(activity.id) == activity + end + + test "create_activity/1 with valid data creates a activity" do + valid_attrs = %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + + assert {:ok, %Activity{} = activity} = Activities.create_activity(valid_attrs) + assert activity.type == "some type" + assert activity.cc == ["option1", "option2"] + assert activity.to == ["option1", "option2"] + assert activity.ap_id == "some ap_id" + assert activity.actor == "some actor" + assert activity.object == "some object" + end + + test "create_activity/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_activity(@invalid_attrs) + end + + test "update_activity/2 with valid data updates the activity" do + activity = activity_fixture() + + update_attrs = %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + + assert {:ok, %Activity{} = activity} = Activities.update_activity(activity, update_attrs) + assert activity.type == "some updated type" + assert activity.cc == ["option1"] + assert activity.to == ["option1"] + assert activity.ap_id == "some updated ap_id" + assert activity.actor == "some updated actor" + assert activity.object == "some updated object" + end + + test "update_activity/2 with invalid data returns error changeset" do + activity = activity_fixture() + assert {:error, %Ecto.Changeset{}} = Activities.update_activity(activity, @invalid_attrs) + assert activity == Activities.get_activity!(activity.id) + end + + test "delete_activity/1 deletes the activity" do + activity = activity_fixture() + assert {:ok, %Activity{}} = Activities.delete_activity(activity) + assert_raise Ecto.NoResultsError, fn -> Activities.get_activity!(activity.id) end + end + + test "change_activity/1 returns a activity changeset" do + activity = activity_fixture() + assert %Ecto.Changeset{} = Activities.change_activity(activity) + end + end +end diff --git a/test/nulla/actors_test.exs b/test/nulla/actors_test.exs new file mode 100644 index 0000000..bc2a88d --- /dev/null +++ b/test/nulla/actors_test.exs @@ -0,0 +1,210 @@ +defmodule Nulla.ActorsTest do + use Nulla.DataCase + + alias Nulla.KeyGen + alias Nulla.Actors + + describe "actors" do + alias Nulla.Actors.Actor + + import Nulla.ActorsFixtures + + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + privateKeyPem: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + test "list_actors/0 returns all actors" do + actor = actor_fixture() + assert Actors.list_actors() == [actor] + end + + test "get_actor!/1 returns the actor with given id" do + actor = actor_fixture() + assert Actors.get_actor!(actor.id) == actor + end + + test "create_actor/1 with valid data creates a actor" do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + valid_attrs = %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: username, + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.create_actor(valid_attrs) + assert actor.name == "some name" + assert actor.tag == [] + assert actor.type == "some type" + assert actor.image == %{} + assert actor.url == "some url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some following" + assert actor.followers == "some followers" + assert actor.inbox == "some inbox" + assert actor.outbox == "some outbox" + assert actor.featured == "some featured" + assert actor.featuredTags == "some featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some summary" + assert actor.manuallyApprovesFollowers == true + assert actor.discoverable == true + assert actor.indexable == true + assert actor.published == ~U[2025-06-30 13:31:00Z] + assert actor.memorial == true + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-06-30] + assert actor.vcard_Address == "some vcard_Address" + end + + test "create_actor/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Actors.create_actor(@invalid_attrs) + end + + test "update_actor/2 with valid data updates the actor" do + actor = actor_fixture() + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + update_attrs = %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: username, + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.update_actor(actor, update_attrs) + assert actor.name == "some updated name" + assert actor.tag == [] + assert actor.type == "some updated type" + assert actor.image == %{} + assert actor.url == "some updated url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some updated following" + assert actor.followers == "some updated followers" + assert actor.inbox == "some updated inbox" + assert actor.outbox == "some updated outbox" + assert actor.featured == "some updated featured" + assert actor.featuredTags == "some updated featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some updated summary" + assert actor.manuallyApprovesFollowers == false + assert actor.discoverable == false + assert actor.indexable == false + assert actor.published == ~U[2025-07-01 13:31:00Z] + assert actor.memorial == false + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-07-01] + assert actor.vcard_Address == "some updated vcard_Address" + end + + test "update_actor/2 with invalid data returns error changeset" do + actor = actor_fixture() + assert {:error, %Ecto.Changeset{}} = Actors.update_actor(actor, @invalid_attrs) + assert actor == Actors.get_actor!(actor.id) + end + + test "delete_actor/1 deletes the actor" do + actor = actor_fixture() + assert {:ok, %Actor{}} = Actors.delete_actor(actor) + assert_raise Ecto.NoResultsError, fn -> Actors.get_actor!(actor.id) end + end + + test "change_actor/1 returns a actor changeset" do + actor = actor_fixture() + assert %Ecto.Changeset{} = Actors.change_actor(actor) + end + end +end diff --git a/test/nulla/media_attachments_test.exs b/test/nulla/media_attachments_test.exs new file mode 100644 index 0000000..9b509eb --- /dev/null +++ b/test/nulla/media_attachments_test.exs @@ -0,0 +1,113 @@ +defmodule Nulla.MediaAttachmentsTest do + use Nulla.DataCase + + alias Nulla.MediaAttachments + + describe "media_attachments" do + alias Nulla.MediaAttachments.MediaAttachment + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + @invalid_attrs %{ + name: nil, + type: nil, + width: nil, + url: nil, + mediaType: nil, + height: nil, + note_id: nil + } + + test "list_media_attachments/0 returns all media_attachments" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.list_media_attachments() == [media_attachment] + end + + test "get_media_attachment!/1 returns the media_attachment with given id" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.get_media_attachment!(media_attachment.id) == media_attachment + end + + test "create_media_attachment/1 with valid data creates a media_attachment" do + note = note_fixture() + + valid_attrs = %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.create_media_attachment(valid_attrs) + + assert media_attachment.name == "some name" + assert media_attachment.type == "some type" + assert media_attachment.width == 42 + assert media_attachment.url == "some url" + assert media_attachment.mediaType == "some mediaType" + assert media_attachment.height == 42 + assert media_attachment.note_id == note.id + end + + test "create_media_attachment/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.create_media_attachment(@invalid_attrs) + end + + test "update_media_attachment/2 with valid data updates the media_attachment" do + note = note_fixture() + media_attachment = media_attachment_fixture() + + update_attrs = %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.update_media_attachment(media_attachment, update_attrs) + + assert media_attachment.name == "some updated name" + assert media_attachment.type == "some updated type" + assert media_attachment.width == 43 + assert media_attachment.url == "some updated url" + assert media_attachment.mediaType == "some updated mediaType" + assert media_attachment.height == 43 + assert media_attachment.note_id == note.id + end + + test "update_media_attachment/2 with invalid data returns error changeset" do + media_attachment = media_attachment_fixture() + + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.update_media_attachment(media_attachment, @invalid_attrs) + + assert media_attachment == MediaAttachments.get_media_attachment!(media_attachment.id) + end + + test "delete_media_attachment/1 deletes the media_attachment" do + media_attachment = media_attachment_fixture() + + assert {:ok, %MediaAttachment{}} = + MediaAttachments.delete_media_attachment(media_attachment) + + assert_raise Ecto.NoResultsError, fn -> + MediaAttachments.get_media_attachment!(media_attachment.id) + end + end + + test "change_media_attachment/1 returns a media_attachment changeset" do + media_attachment = media_attachment_fixture() + assert %Ecto.Changeset{} = MediaAttachments.change_media_attachment(media_attachment) + end + end +end diff --git a/test/nulla/notes_test.exs b/test/nulla/notes_test.exs new file mode 100644 index 0000000..d200212 --- /dev/null +++ b/test/nulla/notes_test.exs @@ -0,0 +1,115 @@ +defmodule Nulla.NotesTest do + use Nulla.DataCase + + alias Nulla.Notes + + describe "notes" do + alias Nulla.Notes.Note + + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil, + actor_id: nil + } + + test "list_notes/0 returns all notes" do + note = note_fixture() + assert Notes.list_notes() == [note] + end + + test "get_note!/1 returns the note with given id" do + note = note_fixture() + assert Notes.get_note!(note.id) == note + end + + test "create_note/1 with valid data creates a note" do + actor = actor_fixture() + + valid_attrs = %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.create_note(valid_attrs) + assert note.sensitive == true + assert note.cc == ["option1", "option2"] + assert note.to == ["option1", "option2"] + assert note.url == "some url" + assert note.language == "some language" + assert note.inReplyTo == "some inReplyTo" + assert note.published == ~U[2025-07-01 09:17:00Z] + assert note.visibility == "some visibility" + assert note.content == "some content" + assert note.actor_id == actor.id + end + + test "create_note/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Notes.create_note(@invalid_attrs) + end + + test "update_note/2 with valid data updates the note" do + actor = actor_fixture() + note = note_fixture() + + update_attrs = %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.update_note(note, update_attrs) + assert note.sensitive == false + assert note.cc == ["option1"] + assert note.to == ["option1"] + assert note.url == "some updated url" + assert note.language == "some updated language" + assert note.inReplyTo == "some updated inReplyTo" + assert note.published == ~U[2025-07-02 09:17:00Z] + assert note.visibility == "some updated visibility" + assert note.content == "some updated content" + assert note.actor_id == actor.id + end + + test "update_note/2 with invalid data returns error changeset" do + note = note_fixture() + assert {:error, %Ecto.Changeset{}} = Notes.update_note(note, @invalid_attrs) + assert note == Notes.get_note!(note.id) + end + + test "delete_note/1 deletes the note" do + note = note_fixture() + assert {:ok, %Note{}} = Notes.delete_note(note) + assert_raise Ecto.NoResultsError, fn -> Notes.get_note!(note.id) end + end + + test "change_note/1 returns a note changeset" do + note = note_fixture() + assert %Ecto.Changeset{} = Notes.change_note(note) + end + end +end diff --git a/test/nulla/relations_test.exs b/test/nulla/relations_test.exs new file mode 100644 index 0000000..a4e45c5 --- /dev/null +++ b/test/nulla/relations_test.exs @@ -0,0 +1,137 @@ +defmodule Nulla.RelationsTest do + use Nulla.DataCase + + alias Nulla.Relations + + describe "relations" do + alias Nulla.Relations.Relation + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil, + local_actor_id: nil, + remote_actor_id: nil + } + + test "list_relations/0 returns all relations" do + relation = relation_fixture() + assert Relations.list_relations() == [relation] + end + + test "get_relation!/1 returns the relation with given id" do + relation = relation_fixture() + assert Relations.get_relation!(relation.id) == relation + end + + test "create_relation/1 with valid data creates a relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + valid_attrs = %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.create_relation(valid_attrs) + assert relation.following == true + assert relation.followed_by == true + assert relation.showing_replies == true + assert relation.showings_reblogs == true + assert relation.notifying == true + assert relation.muting == true + assert relation.muting_notifications == true + assert relation.blocking == true + assert relation.blocked_by == true + assert relation.domain_blocking == true + assert relation.requested == true + assert relation.note == "some note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "create_relation/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Relations.create_relation(@invalid_attrs) + end + + test "update_relation/2 with valid data updates the relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + relation = relation_fixture() + + update_attrs = %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.update_relation(relation, update_attrs) + assert relation.following == false + assert relation.followed_by == false + assert relation.showing_replies == false + assert relation.showings_reblogs == false + assert relation.notifying == false + assert relation.muting == false + assert relation.muting_notifications == false + assert relation.blocking == false + assert relation.blocked_by == false + assert relation.domain_blocking == false + assert relation.requested == false + assert relation.note == "some updated note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "update_relation/2 with invalid data returns error changeset" do + relation = relation_fixture() + assert {:error, %Ecto.Changeset{}} = Relations.update_relation(relation, @invalid_attrs) + assert relation == Relations.get_relation!(relation.id) + end + + test "delete_relation/1 deletes the relation" do + relation = relation_fixture() + assert {:ok, %Relation{}} = Relations.delete_relation(relation) + assert_raise Ecto.NoResultsError, fn -> Relations.get_relation!(relation.id) end + end + + test "change_relation/1 returns a relation changeset" do + relation = relation_fixture() + assert %Ecto.Changeset{} = Relations.change_relation(relation) + end + end +end diff --git a/test/nulla_web/controllers/activity_controller_test.exs b/test/nulla_web/controllers/activity_controller_test.exs new file mode 100644 index 0000000..1ae210c --- /dev/null +++ b/test/nulla_web/controllers/activity_controller_test.exs @@ -0,0 +1,107 @@ +defmodule NullaWeb.ActivityControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActivitiesFixtures + + alias Nulla.Activities.Activity + + @create_attrs %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + @update_attrs %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all activities", %{conn: conn} do + conn = get(conn, ~p"/api/activities") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create activity" do + test "renders activity when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some actor", + "ap_id" => "some ap_id", + "cc" => ["option1", "option2"], + "object" => "some object", + "to" => ["option1", "option2"], + "type" => "some type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update activity" do + setup [:create_activity] + + test "renders activity when data is valid", %{ + conn: conn, + activity: %Activity{id: id} = activity + } do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some updated actor", + "ap_id" => "some updated ap_id", + "cc" => ["option1"], + "object" => "some updated object", + "to" => ["option1"], + "type" => "some updated type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, activity: activity} do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete activity" do + setup [:create_activity] + + test "deletes chosen activity", %{conn: conn, activity: activity} do + conn = delete(conn, ~p"/api/activities/#{activity}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/activities/#{activity}") + end + end + end + + defp create_activity(_) do + activity = activity_fixture() + %{activity: activity} + end +end diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/actor_controller_test.exs new file mode 100644 index 0000000..39f5224 --- /dev/null +++ b/test/nulla_web/controllers/actor_controller_test.exs @@ -0,0 +1,211 @@ +defmodule NullaWeb.ActorControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + + alias Nulla.Actors.Actor + + @create_attrs %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "some acct", + ap_id: "some ap_id", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: "some preferredUsername", + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + @update_attrs %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "some updated acct", + ap_id: "some updated ap_id", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: "some updated preferredUsername", + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all actors", %{conn: conn} do + conn = get(conn, ~p"/api/actors") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create actor" do + test "renders actor when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some acct", + "ap_id" => "some ap_id", + "attachment" => [], + "discoverable" => true, + "endpoints" => %{}, + "featured" => "some featured", + "featuredTags" => "some featuredTags", + "followers" => "some followers", + "following" => "some following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some inbox", + "indexable" => true, + "manuallyApprovesFollowers" => true, + "memorial" => true, + "name" => "some name", + "outbox" => "some outbox", + "preferredUsername" => "some preferredUsername", + "publicKey" => %{}, + "published" => "2025-06-30T13:31:00Z", + "summary" => "some summary", + "tag" => [], + "type" => "some type", + "url" => "some url", + "vcard_Address" => "some vcard_Address", + "vcard_bday" => "2025-06-30" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update actor" do + setup [:create_actor] + + test "renders actor when data is valid", %{conn: conn, actor: %Actor{id: id} = actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some updated acct", + "ap_id" => "some updated ap_id", + "attachment" => [], + "discoverable" => false, + "endpoints" => %{}, + "featured" => "some updated featured", + "featuredTags" => "some updated featuredTags", + "followers" => "some updated followers", + "following" => "some updated following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some updated inbox", + "indexable" => false, + "manuallyApprovesFollowers" => false, + "memorial" => false, + "name" => "some updated name", + "outbox" => "some updated outbox", + "preferredUsername" => "some updated preferredUsername", + "publicKey" => %{}, + "published" => "2025-07-01T13:31:00Z", + "summary" => "some updated summary", + "tag" => [], + "type" => "some updated type", + "url" => "some updated url", + "vcard_Address" => "some updated vcard_Address", + "vcard_bday" => "2025-07-01" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, actor: actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete actor" do + setup [:create_actor] + + test "deletes chosen actor", %{conn: conn, actor: actor} do + conn = delete(conn, ~p"/api/actors/#{actor}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/actors/#{actor}") + end + end + end + + defp create_actor(_) do + actor = actor_fixture() + %{actor: actor} + end +end diff --git a/test/nulla_web/controllers/media_attachment_controller_test.exs b/test/nulla_web/controllers/media_attachment_controller_test.exs new file mode 100644 index 0000000..b443cc7 --- /dev/null +++ b/test/nulla_web/controllers/media_attachment_controller_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.MediaAttachmentControllerTest do + use NullaWeb.ConnCase + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + alias Nulla.MediaAttachments.MediaAttachment + + @create_attrs %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42 + } + @update_attrs %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43 + } + @invalid_attrs %{name: nil, type: nil, width: nil, url: nil, mediaType: nil, height: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all media_attachments", %{conn: conn} do + conn = get(conn, ~p"/api/media_attachments") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create media_attachment" do + test "renders media_attachment when data is valid", %{conn: conn} do + note = note_fixture() + + create_attrs = Map.merge(@create_attrs, %{note_id: note.id}) + + conn = post(conn, ~p"/api/media_attachments", media_attachment: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 42, + "mediaType" => "some mediaType", + "name" => "some name", + "type" => "some type", + "url" => "some url", + "width" => 42 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/media_attachments", media_attachment: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update media_attachment" do + setup [:create_media_attachment] + + test "renders media_attachment when data is valid", %{ + conn: conn, + media_attachment: %MediaAttachment{id: id} = media_attachment + } do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", media_attachment: @update_attrs) + + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 43, + "mediaType" => "some updated mediaType", + "name" => "some updated name", + "type" => "some updated type", + "url" => "some updated url", + "width" => 43 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, media_attachment: media_attachment} do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", + media_attachment: @invalid_attrs + ) + + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete media_attachment" do + setup [:create_media_attachment] + + test "deletes chosen media_attachment", %{conn: conn, media_attachment: media_attachment} do + conn = delete(conn, ~p"/api/media_attachments/#{media_attachment}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/media_attachments/#{media_attachment}") + end + end + end + + defp create_media_attachment(_) do + media_attachment = media_attachment_fixture() + %{media_attachment: media_attachment} + end +end diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs new file mode 100644 index 0000000..b5855fa --- /dev/null +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -0,0 +1,131 @@ +defmodule NullaWeb.NoteControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + alias Nulla.Notes.Note + + @create_attrs %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content" + } + @update_attrs %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content" + } + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all notes", %{conn: conn} do + conn = get(conn, ~p"/api/notes") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create note" do + test "renders note when data is valid", %{conn: conn} do + actor = actor_fixture() + + create_attrs = Map.merge(@create_attrs, %{actor_id: actor.id}) + + conn = post(conn, ~p"/api/notes", note: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1", "option2"], + "content" => "some content", + "inReplyTo" => "some inReplyTo", + "language" => "some language", + "published" => "2025-07-01T09:17:00Z", + "sensitive" => true, + "to" => ["option1", "option2"], + "url" => "some url", + "visibility" => "some visibility" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/notes", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update note" do + setup [:create_note] + + test "renders note when data is valid", %{conn: conn, note: %Note{id: id} = note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1"], + "content" => "some updated content", + "inReplyTo" => "some updated inReplyTo", + "language" => "some updated language", + "published" => "2025-07-02T09:17:00Z", + "sensitive" => false, + "to" => ["option1"], + "url" => "some updated url", + "visibility" => "some updated visibility" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, note: note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete note" do + setup [:create_note] + + test "deletes chosen note", %{conn: conn, note: note} do + conn = delete(conn, ~p"/api/notes/#{note}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/notes/#{note}") + end + end + end + + defp create_note(_) do + note = note_fixture() + %{note: note} + end +end diff --git a/test/nulla_web/controllers/relation_controller_test.exs b/test/nulla_web/controllers/relation_controller_test.exs new file mode 100644 index 0000000..a221d33 --- /dev/null +++ b/test/nulla_web/controllers/relation_controller_test.exs @@ -0,0 +1,154 @@ +defmodule NullaWeb.RelationControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + alias Nulla.Relations.Relation + + @create_attrs %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note" + } + @update_attrs %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note" + } + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all relations", %{conn: conn} do + conn = get(conn, ~p"/api/relations") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create relation" do + test "renders relation when data is valid", %{conn: conn} do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + create_attrs = + Map.merge(@create_attrs, %{ + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + + conn = post(conn, ~p"/api/relations", relation: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => true, + "blocking" => true, + "domain_blocking" => true, + "followed_by" => true, + "following" => true, + "muting" => true, + "muting_notifications" => true, + "note" => "some note", + "notifying" => true, + "requested" => true, + "showing_replies" => true, + "showings_reblogs" => true + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/relations", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update relation" do + setup [:create_relation] + + test "renders relation when data is valid", %{ + conn: conn, + relation: %Relation{id: id} = relation + } do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "followed_by" => false, + "following" => false, + "muting" => false, + "muting_notifications" => false, + "note" => "some updated note", + "notifying" => false, + "requested" => false, + "showing_replies" => false, + "showings_reblogs" => false + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, relation: relation} do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete relation" do + setup [:create_relation] + + test "deletes chosen relation", %{conn: conn, relation: relation} do + conn = delete(conn, ~p"/api/relations/#{relation}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/relations/#{relation}") + end + end + end + + defp create_relation(_) do + relation = relation_fixture() + %{relation: relation} + end +end diff --git a/test/nulla_web/controllers/user_session_controller_test.exs b/test/nulla_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..b4d82f0 --- /dev/null +++ b/test/nulla_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule NullaWeb.UserSessionControllerTest do + use NullaWeb.ConnCase, async: true + + import Nulla.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_nulla_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/nulla_web/live/user_confirmation_instructions_live_test.exs b/test/nulla_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..0b13cdd --- /dev/null +++ b/test/nulla_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule NullaWeb.UserConfirmationInstructionsLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_confirmation_live_test.exs b/test/nulla_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..561e757 --- /dev/null +++ b/test/nulla_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserConfirmationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/nulla_web/live/user_forgot_password_live_test.exs b/test/nulla_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..848b93f --- /dev/null +++ b/test/nulla_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule NullaWeb.UserForgotPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_login_live_test.exs b/test/nulla_web/live/user_login_live_test.exs new file mode 100644 index 0000000..cc83e65 --- /dev/null +++ b/test/nulla_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserLoginLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/nulla_web/live/user_registration_live_test.exs b/test/nulla_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..f85952d --- /dev/null +++ b/test/nulla_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/nulla_web/live/user_reset_password_live_test.exs b/test/nulla_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..0039d31 --- /dev/null +++ b/test/nulla_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.UserResetPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/nulla_web/live/user_settings_live_test.exs b/test/nulla_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..ee8c098 --- /dev/null +++ b/test/nulla_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule NullaWeb.UserSettingsLiveTest do + use NullaWeb.ConnCase, async: true + + alias Nulla.Accounts + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/nulla_web/user_auth_test.exs b/test/nulla_web/user_auth_test.exs new file mode 100644 index 0000000..328a049 --- /dev/null +++ b/test/nulla_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule NullaWeb.UserAuthTest do + use NullaWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias Nulla.Accounts + alias NullaWeb.UserAuth + import Nulla.AccountsFixtures + + @remember_me_cookie "_nulla_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, NullaWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + NullaWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 10ed035..ce34e8d 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule NullaWeb.ConnCase do Nulla.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = Nulla.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Nulla.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..e37764f --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Nulla.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Nulla.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex new file mode 100644 index 0000000..d9884ac --- /dev/null +++ b/test/support/fixtures/activities_fixtures.ex @@ -0,0 +1,25 @@ +defmodule Nulla.ActivitiesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Activities` context. + """ + + @doc """ + Generate a activity. + """ + def activity_fixture(attrs \\ %{}) do + {:ok, activity} = + attrs + |> Enum.into(%{ + actor: "some actor", + ap_id: "some ap_id", + cc: ["option1", "option2"], + object: "some object", + to: ["option1", "option2"], + type: "some type" + }) + |> Nulla.Activities.create_activity() + + activity + end +end diff --git a/test/support/fixtures/actors_fixtures.ex b/test/support/fixtures/actors_fixtures.ex new file mode 100644 index 0000000..2381cbe --- /dev/null +++ b/test/support/fixtures/actors_fixtures.ex @@ -0,0 +1,60 @@ +defmodule Nulla.ActorsFixtures do + alias Nulla.KeyGen + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Actors` context. + """ + + @doc """ + Generate a actor. + """ + def actor_fixture(attrs \\ %{}) do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + attrs = + Enum.into(attrs, %{ + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + attachment: [], + discoverable: true, + endpoints: %{}, + featured: "some featured", + featuredTags: "some featuredTags", + followers: "some followers", + following: "some following", + icon: %{}, + image: %{}, + inbox: "some inbox", + indexable: true, + manuallyApprovesFollowers: true, + memorial: true, + name: "some name", + outbox: "some outbox", + preferredUsername: username, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + published: ~U[2025-06-30 13:31:00Z], + summary: "some summary", + tag: [], + type: "some type", + url: "some url", + vcard_Address: "some vcard_Address", + vcard_bday: ~D[2025-06-30] + }) + + case Nulla.Actors.create_actor(attrs) do + {:ok, actor} -> + actor + + {:error, changeset} -> + IO.inspect(changeset, label: "Actor creation failed") + raise "Failed to create actor fixture" + end + end +end diff --git a/test/support/fixtures/media_attachments_fixtures.ex b/test/support/fixtures/media_attachments_fixtures.ex new file mode 100644 index 0000000..c9bd54f --- /dev/null +++ b/test/support/fixtures/media_attachments_fixtures.ex @@ -0,0 +1,30 @@ +defmodule Nulla.MediaAttachmentsFixtures do + import Nulla.NotesFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.MediaAttachments` context. + """ + + @doc """ + Generate a media_attachment. + """ + def media_attachment_fixture(attrs \\ %{}) do + note = note_fixture() + + {:ok, media_attachment} = + attrs + |> Enum.into(%{ + height: 42, + mediaType: "some mediaType", + name: "some name", + type: "some type", + url: "some url", + width: 42, + note_id: note.id + }) + |> Nulla.MediaAttachments.create_media_attachment() + + media_attachment + end +end diff --git a/test/support/fixtures/notes_fixtures.ex b/test/support/fixtures/notes_fixtures.ex new file mode 100644 index 0000000..a3d34b1 --- /dev/null +++ b/test/support/fixtures/notes_fixtures.ex @@ -0,0 +1,33 @@ +defmodule Nulla.NotesFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Notes` context. + """ + + @doc """ + Generate a note. + """ + def note_fixture(attrs \\ %{}) do + actor = actor_fixture() + + {:ok, note} = + attrs + |> Enum.into(%{ + cc: ["option1", "option2"], + content: "some content", + inReplyTo: "some inReplyTo", + language: "some language", + published: ~U[2025-07-01 09:17:00Z], + sensitive: true, + to: ["option1", "option2"], + url: "some url", + visibility: "some visibility", + actor_id: actor.id + }) + |> Nulla.Notes.create_note() + + note + end +end diff --git a/test/support/fixtures/relations_fixtures.ex b/test/support/fixtures/relations_fixtures.ex new file mode 100644 index 0000000..ca7ef41 --- /dev/null +++ b/test/support/fixtures/relations_fixtures.ex @@ -0,0 +1,38 @@ +defmodule Nulla.RelationsFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Relations` context. + """ + + @doc """ + Generate a relation. + """ + def relation_fixture(attrs \\ %{}) do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + {:ok, relation} = + attrs + |> Enum.into(%{ + blocked_by: true, + blocking: true, + domain_blocking: true, + followed_by: true, + following: true, + muting: true, + muting_notifications: true, + note: "some note", + notifying: true, + requested: true, + showing_replies: true, + showings_reblogs: true, + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + |> Nulla.Relations.create_relation() + + relation + end +end