diff options
-rw-r--r-- | LICENSE.md | 660 | ||||
-rw-r--r-- | README.md | 92 | ||||
-rw-r--r-- | index.js | 10 | ||||
-rw-r--r-- | lib/app.js | 102 | ||||
-rw-r--r-- | lib/markdown-inline.js | 49 | ||||
-rw-r--r-- | lib/render-msg.js | 337 | ||||
-rw-r--r-- | lib/render.js | 203 | ||||
-rw-r--r-- | lib/serve.js | 774 | ||||
-rw-r--r-- | lib/util.js | 80 | ||||
-rw-r--r-- | package.json | 33 | ||||
-rw-r--r-- | server.js | 4 | ||||
-rw-r--r-- | static/fallback.png | bin | 0 -> 3346 bytes | |||
-rw-r--r-- | static/styles.css | 112 |
13 files changed, 2456 insertions, 0 deletions
diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..88f1b84 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,660 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +<http://fsf.org/> + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + +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. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero 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 Affero 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 Affero 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 Affero 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +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 AGPL, see <http://www.gnu.org/licenses/>.
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ede3d0 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# patchfoo + +Plain SSB web UI. Uses HTML forms instead of client-side JS. Designed for use on low-power and low-resource computers. + +## Goals + +- Support all message schemas commonly used in the main SSB network. +- Make efficient use of screen space, memory, and CPU. +- Run well in [dillo](http://dillo.org/) browser. +- Serve as a place for experimenting with new HTML-based SSB UIs. + +## Features + +- Render messages with author name and icons. +- Render core ssb message types, git-ssb message types, and raw messages. +- View public log, private log, user feeds, channels, and search. +- Paginate views bidirectionally. +- Compose, preview and publish public and private messages. + +## TODO + +- Support more message types (e.g. ferment). +- Add a way to assist picking feed ids for `@mentions` in composer. +- Show a list of channels. +- Add more sophisticated private messages view. +- Show contents of git repos (cross-develop with [patchbay]) +- Count digs +- Show followers/followed on feed pages. + - Add form for (un)following feeds. +- Show network status +- Add UI for using pub invites + +## Install & Run + +As a sbot plugin: +```sh +cd ~/.ssb/node_modules +git clone ssb://%YAg1hicat+2GELjE2QJzDwlAWcx0ML+1sXEdsWwvdt8=.sha256 patchfoo && cd patchfoo +npm install --production +sbot plugins.enable patchfoo +# restart sbot +``` + +Or standalone: +```sh +git clone ssb://%YAg1hicat+2GELjE2QJzDwlAWcx0ML+1sXEdsWwvdt8=.sha256 patchfoo && cd patchfoo +npm install +npm start +``` + +## Config + +Pass config options with args +e.g. `npm start -- --patchfoo.port 8027` if running standalone, +or `sbot server --patchfoo.port 8027` if running as an sbot plugin. +To make config options persistent, set them in `~/.ssb/config`, e.g.: +```json +{ + "patchfoo": { + "port": 8027, + "host": "::" + } +} +``` + +### Config options + +- `port`: port for the server to listen on. default: `8027` +- `host`: host address for the server to listen on. default: `localhost` +- `base`: base url that the app is running at. default: `/` +- `blob_base`: base url for links to ssb blobs. default: same as `base` +- `img_base`: base url for blobs embedded as images. default: same as `base` +- `emoji_base`: base url for emoji images. default: same as `base` + +[patchbay]: %s9mSFATE4RGyJx9wgH22lBrvD4CgUQW4yeguSWWjtqc=.sha256 + +## License + +Copyright (C) 2017 Secure Scuttlebutt Consortium + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. diff --git a/index.js b/index.js new file mode 100644 index 0000000..91de3fc --- /dev/null +++ b/index.js @@ -0,0 +1,10 @@ +var pkg = require('./package') +exports.name = pkg.name +exports.version = pkg.version +exports.manifest = {} + +var App = require('./lib/app') + +exports.init = function (sbot, config) { + new App(sbot, config).go() +} diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..de8585d --- /dev/null +++ b/lib/app.js @@ -0,0 +1,102 @@ +var http = require('http') +var memo = require('asyncmemo') +var lru = require('lrucache') +var pkg = require('../package') +var u = require('./util') + +var Serve = require('./serve') +var Render = require('./render') + +module.exports = App + +function App(sbot, config) { + this.sbot = sbot + this.config = config + + var conf = config.patchfoo || {} + this.port = conf.port || 8027 + this.host = conf.host || 'localhost' + + var base = conf.base || '/' + this.opts = { + base: base, + blob_base: conf.blob_base || conf.img_base || base, + img_base: conf.img_base || base, + emoji_base: conf.emoji_base || (base + 'emoji/'), + } + + sbot.get = memo({cache: lru(100)}, sbot.get) + this.getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot) + this.getAbout = memo({cache: lru(100)}, require('ssb-avatar'), sbot, sbot.id) + this.unboxContent = memo({cache: lru(100)}, sbot.private.unbox) + + this.unboxMsg = this.unboxMsg.bind(this) + + this.render = new Render(this, this.opts) +} + +App.prototype.go = function () { + var self = this + http.createServer(function (req, res) { + new Serve(self, req, res).go() + }).listen(self.port, self.host, function () { + self.log('Listening on http://' + self.host + ':' + self.port) + }) +} + +App.prototype.logPrefix = ['[' + pkg.name + ']'] + +App.prototype.log = function () { + console.log.apply(console, [].concat.apply(this.logPrefix, arguments)) +} + +App.prototype.error = function () { + console.error.apply(console, [].concat.apply(this.logPrefix, arguments)) +} + +App.prototype.unboxMsg = function (msg, cb) { + var self = this + var c = msg.value.content + if (typeof c !== 'string') cb(null, msg) + else self.unboxContent(c, function (err, content) { + if (err) { + self.error('unbox:', err) + return cb(null, msg) + } + var m = {} + for (var k in msg) m[k] = msg[k] + m.value = {} + for (var k in msg.value) m.value[k] = msg.value[k] + m.value.content = content + m.value.private = true + cb(null, m) + }) +} + +App.prototype.search = function (opts) { + return this.sbot.fulltext.search(opts) +} + +App.prototype.getMsgDecrypted = function (key, cb) { + var self = this + this.getMsg(key, function (err, msg) { + if (err) return cb(err) + self.unboxMsg(msg, cb) + }) +} + +App.prototype.publish = function (content, cb) { + if (Array.isArray(content.recps)) { + recps = content.recps.map(u.linkDest) + this.sbot.private.publish(content, recps, cb) + } else { + this.sbot.publish(content, cb) + } +} + +function getMsgWithValue(sbot, id, cb) { + sbot.get(id, function (err, value) { + if (err) return cb(err) + cb(null, {key: id, value: value}) + }) +} diff --git a/lib/markdown-inline.js b/lib/markdown-inline.js new file mode 100644 index 0000000..2f1e696 --- /dev/null +++ b/lib/markdown-inline.js @@ -0,0 +1,49 @@ +var marked = require('ssb-marked') +var u = require('./util') + +// based on ssb-markdown, which is Copyright (c) 2016 Dominic Tarr, MIT License + +var inlineRenderer = new marked.Renderer() + +// inline renderer just spits out the text of links and images +inlineRenderer.urltransform = function (url) { return false } +inlineRenderer.link = function (href, title, text) { return unquote(shortenIfLink(text)) } +inlineRenderer.image = function (href, title, text) { return unquote(shortenIfLink(text)) } +inlineRenderer.code = function(code, lang, escaped) { return escaped ? code : escape(code) } +inlineRenderer.blockquote = function(quote) { return unquote(quote) } +inlineRenderer.html = function(html) { return false } +inlineRenderer.heading = function(text, level, raw) { return unquote(text)+' ' } +inlineRenderer.hr = function() { return ' --- ' } +inlineRenderer.br = function() { return ' ' } +inlineRenderer.list = function(body, ordered) { return unquote(body) } +inlineRenderer.listitem = function(text) { return '- '+unquote(text) } +inlineRenderer.paragraph = function(text) { return unquote(text)+' ' } +inlineRenderer.table = function(header, body) { return unquote(header + ' ' + body) } +inlineRenderer.tablerow = function(content) { return unquote(content) } +inlineRenderer.tablecell = function(content, flags) { return unquote(content) } +inlineRenderer.strong = function(text) { return unquote(text) } +inlineRenderer.em = function(text) { return unquote(text) } +inlineRenderer.codespan = function(text) { return unquote(text) } +inlineRenderer.del = function(text) { return unquote(text) } +inlineRenderer.mention = function(preceding, id) { return shortenIfLink(unquote((preceding||'') + id)) } + +function unquote (text) { + return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '\'') +} + +function escape (text) { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\n+/g, ' ') +} + +function shortenIfLink (text) { + return (u.ssbRefRegex.test(text.trim())) ? text.slice(0, 8) : text +} + +module.exports = function(text) { + return marked(''+(text||''), {renderer: inlineRenderer, emoji: false}) +} diff --git a/lib/render-msg.js b/lib/render-msg.js new file mode 100644 index 0000000..bc0d383 --- /dev/null +++ b/lib/render-msg.js @@ -0,0 +1,337 @@ +var h = require('hyperscript') +var htime = require('human-time') +var multicb = require('multicb') +var u = require('./util') +var mdInline = require('./markdown-inline') + +module.exports = RenderMsg + +function RenderMsg(render, app, msg, opts) { + this.render = render + this.app = app + this.msg = msg + var opts = opts || {} + this.shouldWrap = opts.wrap !== false + + this.c = msg.value.content || {} +} + +RenderMsg.prototype.toUrl = function (href) { + return this.render.toUrl(href) +} + +RenderMsg.prototype.linkify = function (text) { + var arr = text.split(u.ssbRefRegex) + for (var i = 1; i < arr.length; i += 2) { + arr[i] = h('a', {href: this.toUrl(arr[i])}, arr[i]) + } + return arr +} + +RenderMsg.prototype.raw = function (cb) { + this.wrap(h('pre', + this.linkify(JSON.stringify(this.msg, 0, 2)) + ), cb) +} + +RenderMsg.prototype.wrap = function (content, cb) { + if (!this.shouldWrap) return cb(null, content) + var date = new Date(this.msg.value.timestamp) + var self = this + var channel = this.c.channel ? '#' + this.c.channel : '' + var done = multicb({pluck: 1, spread: true}) + done()(null, h('tr.msg-row', + h('td.msg-left', + h('div', this.render.avatarImage(this.msg.value.author, done())), + h('div', this.render.idLink(this.msg.value.author, done())), + this.recpsLine(done()) + ), + h('td.msg-main', + h('div.msg-header', + h('a.ssb-timestamp', { + title: date.toLocaleString(), + href: this.msg.key ? this.toUrl(this.msg.key) : undefined + }, htime(date)), ' ', + h('code.ssb-id', + {href: this.toUrl(this.msg.key)}, this.msg.key), + channel ? [' ', h('a', {href: this.toUrl(channel)}, channel)] : ''), + content), + h('td.msg-right', + this.msg.rel ? [this.msg.rel, ' '] : '', + this.msg.key ? h('form', {method: 'post', action: '/vote'}, + h('div', h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw')), + h('input', {type: 'hidden', name: 'recps', + value: this.recpsIds().join(',')}), + h('input', {type: 'hidden', name: 'link', value: this.msg.key}), + h('input', {type: 'hidden', name: 'value', value: 1}), + h('input', {type: 'submit', name: 'expression', value: 'dig'}) + ) : '' + ) + )) + done(cb) +} + +RenderMsg.prototype.wrapMini = function (content, cb) { + if (!this.shouldWrap) return cb(null, content) + var date = new Date(this.msg.value.timestamp) + var self = this + var channel = this.c.channel ? '#' + this.c.channel : '' + var done = multicb({pluck: 1, spread: true}) + done()(null, h('tr.msg-row', + h('td.msg-left', + this.render.idLink(this.msg.value.author, done()), ' ', + this.recpsLine(done()), + channel ? [h('a', {href: this.toUrl(channel)}, channel), ' '] : ''), + h('td.msg-main', + h('a.ssb-timestamp', { + title: date.toLocaleString(), + href: this.msg.key ? this.toUrl(this.msg.key) : undefined + }, htime(date)), ' ', + content), + h('td.msg-right', + h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw')) + )) + done(cb) +} + +RenderMsg.prototype.recpsLine = function (cb) { + return this.msg.value.private + ? this.render.privateLine(this.c.recps, cb) + : (cb(), '') +} + +RenderMsg.prototype.recpsIds = function () { + return this.msg.value.private + ? u.toArray(this.c.recps).map(u.linkDest) + : [] +} + +RenderMsg.prototype.message = function (raw, cb) { + if (raw) return this.raw(cb) + if (typeof this.c === 'string') return this.encrypted(cb) + switch (this.c.type) { + case 'post': return this.post(cb) + case 'vote': return this.vote(cb) + case 'about': return this.about(cb) + case 'contact': return this.contact(cb) + case 'pub': return this.pub(cb) + case 'channel': return this.channel(cb) + case 'git-repo': return this.gitRepo(cb) + case 'git-update': return this.gitUpdate(cb) + case 'pull-request': return this.gitPullRequest(cb) + case 'issue': return this.issue(cb) + default: return this.object(cb) + } +} + +RenderMsg.prototype.encrypted = function (cb) { + this.wrapMini(this.render.lockIcon(), cb) +} + +RenderMsg.prototype.markdown = function (cb) { + return this.render.markdown(this.c.text, this.c.mentions) +} + +RenderMsg.prototype.post = function (cb) { + var self = this + self.link(self.c.root, function (err, a) { + if (err) return self.wrap(u.renderError(err), cb) + self.wrap(h('div.ssb-post', + a ? h('div', h('small', 're: ', a)) : '', + h('div.ssb-post-text', {innerHTML: self.markdown()}) + ), cb) + }) +} + +RenderMsg.prototype.vote = function (cb) { + var self = this + var v = self.c.vote || {} + self.link(v, function (err, a) { + if (err) return cb(err) + self.wrapMini([ + v.value > 0 ? 'dug' : v.value < 0 ? 'downvoted' : 'undug', ' ', a], cb) + }) +} + +RenderMsg.prototype.getName = function (id, cb) { + switch (id && id[0]) { + case '%': return this.getMsgName(id, cb) + case '@': // fallthrough + case '&': return this.getAboutName(id, cb) + default: return cb(null, String(id)) + } +} + +RenderMsg.prototype.getMsgName = function (id, cb) { + var self = this + self.app.getMsg(id, function (err, msg) { + if (err && err.name == 'NotFoundError') + cb(null, id.substring(0, 10)+'...(missing)') + else if (err) cb(err) + // preserve security: only decrypt the linked message if we decrypted + // this message + else if (self.msg.value.private) self.app.unboxMsg(msg, gotMsg) + else gotMsg(null, msg) + }) + function gotMsg(err, msg) { + if (err) return cb(err) + new RenderMsg(self.render, self.app, msg, {wrap: false}).title(cb) + } +} + +function truncate(str, len) { + return str.length > len ? str.substr(0, len) + '...' : str +} + +function title(str) { + return truncate(mdInline(str), 40) +} + +RenderMsg.prototype.title = function (cb) { + var self = this + if (typeof self.c.text === 'string') { + if (self.c.type === 'post') + cb(null, title(self.c.text)) + else + cb(null, self.c.type + ':' + (self.c.title || title(self.c.text))) + } else if (self.c.type === 'git-repo') { + self.getAboutName(self.msg.key, cb) + } else { + self.message(false, function (err, el) { + if (err) return cb(err) + cb(null, title(h('div', el).textContent)) + }) + } +} + +RenderMsg.prototype.getAboutName = function (id, cb) { + this.app.getAbout(id, function (err, about) { + cb(err, about && about.name) + }) +} + +RenderMsg.prototype.link = function (link, cb) { + var self = this + var ref = u.linkDest(link) + if (!ref) return cb(null, '') + self.getName(ref, function (err, name) { + if (err) return cb(err) + cb(null, h('a', {href: self.toUrl(ref)}, name)) + }) +} + +RenderMsg.prototype.about = function (cb) { + var img = u.linkDest(this.c.image) + this.wrapMini([ + this.c.about === this.msg.value.author ? 'self-identifies' : + ['identifies ', h('a', {href: this.toUrl(this.c.about)}, truncate(this.c.about, 10))], + ' as ', + this.c.name ? [h('ins', this.c.name), ' '] : '', + img ? [ + h('br'), + h('a', {href: this.toUrl(img)}, + h('img', { + src: this.render.imageUrl(img), + alt: img, + width: 64, + height: 64, + }) + ) + ] : '' + ], cb) +} + +RenderMsg.prototype.contact = function (cb) { + var self = this + self.link(self.c.contact, function (err, a) { + if (err) return cb(err) + self.wrapMini([ + self.c.following ? 'follows' : + self.c.blocking ? 'blocks' : + self.c.following === false ? 'unfollows' : + self.c.blocking === false ? 'unblocks' : '', + ' ', a], cb) + }) +} + +RenderMsg.prototype.pub = function (cb) { + var self = this + var addr = self.c.address || {} + self.link(addr.key, function (err, pubLink) { + if (err) return cb(err) + self.wrapMini([ + 'pub ', pubLink, ': ', + h('code', addr.host + ':' + addr.port)], cb) + }) +} + +RenderMsg.prototype.channel = function (cb) { + var chan = '#' + this.c.channel + this.wrapMini([ + this.c.subscribed ? 'subscribes to ' : + this.c.subscribed === false ? 'unsubscribes from ' : '', + h('a', {href: this.toUrl(chan)}, chan)], cb) +} + +RenderMsg.prototype.gitRepo = function (cb) { + this.wrapMini([ + 'git repo ', + h('code', h('small', 'ssb://' + this.msg.key)), + this.c.name ? [' ', h('a', {href: this.toUrl(this.msg.key)}, + '%' + this.c.name)] : '' + ], cb) +} + +RenderMsg.prototype.gitUpdate = function (cb) { + var self = this + // h('a', {href: self.toUrl(self.c.repo)}, 'ssb://' + self.c.repo), + self.link(self.c.repo, function (err, a) { + if (err) return cb(err) + self.wrap(h('div.ssb-git-update', + 'git push ', a, ' ', + self.c.refs ? h('ul', Object.keys(self.c.refs).map(function (ref) { + var id = self.c.refs[ref] + return h('li', + ref.replace(/^refs\/(heads|tags)\//, ''), ': ', + id ? h('code', id) : h('em', 'deleted')) + })) : '', + Array.isArray(self.c.commits) ? + h('ul', self.c.commits.map(function (commit) { + return h('li', + h('code', String(commit.sha1).substr(0, 8)), ' ', + commit.title) + })) : '' + ), cb) + }) +} + +RenderMsg.prototype.gitPullRequest = function (cb) { + var self = this + var done = multicb({pluck: 1, spread: true}) + self.link(self.c.repo, done()) + self.link(self.c.head_repo, done()) + done(function (err, baseRepoLink, headRepoLink) { + if (err) return cb(err) + self.wrap(h('div.ssb-pull-request', + 'pull request ', + 'to ', baseRepoLink, ':', self.c.branch, ' ', + 'from ', headRepoLink, ':', self.c.head_branch, + self.c.title ? h('h4', self.c.title) : '', + h('div', {innerHTML: self.markdown()})), cb) + }) +} + +RenderMsg.prototype.issue = function (cb) { + var self = this + self.link(self.c.project, function (err, projectLink) { + if (err) return cb(err) + self.wrap(h('div.ssb-issue', + 'issue on ', projectLink, + self.c.title ? h('h4', self.c.title) : '', + h('div', {innerHTML: self.markdown()})), cb) + }) +} + +RenderMsg.prototype.object = function (cb) { + this.wrapMini(h('pre', this.c.type), cb) +} diff --git a/lib/render.js b/lib/render.js new file mode 100644 index 0000000..135f3b3 --- /dev/null +++ b/lib/render.js @@ -0,0 +1,203 @@ +var fs = require('fs') +var path = require('path') +var pull = require('pull-stream') +var cat = require('pull-cat') +var paramap = require('pull-paramap') +var h = require('hyperscript') +var marked = require('ssb-marked') +var emojis = require('emoji-named-characters') +var qs = require('querystring') +var u = require('./util') +var multicb = require('multicb') +var RenderMsg = require('./render-msg') + +module.exports = Render + +function MdRenderer(render) { + marked.Renderer.call(this, {}) + this.render = render +} +MdRenderer.prototype = new marked.Renderer() + +MdRenderer.prototype.urltransform = function (href) { + return this.render.toUrl(href) +} + +MdRenderer.prototype.image = function (href, title, text) { + return h('img', { + src: this.render.imageUrl(href), + alt: text, + title: title || undefined + }).outerHTML +} + +function lexerRenderEmoji(emoji) { + var el = this.renderer.render.emoji(emoji) + return el && el.outerHTML || el +} + +function Render(app, opts) { + this.app = app + this.opts = opts + + this.markedOpts = { + gfm: true, + mentions: true, + tables: true, + breaks: true, + pedantic: false, + sanitize: true, + smartLists: true, + smartypants: false, + emoji: lexerRenderEmoji, + renderer: new MdRenderer(this), + } +} + +Render.prototype.emoji = function (emoji) { + var name = ':' + emoji + ':' + return emoji in emojis ? + h('img.ssb-emoji', { + src: this.opts.emoji_base + emoji + '.png', + alt: name, + title: name, + height: 16, + width: 16 + }) : name +} + +Render.prototype.markdown = function (text, mentions) { + var mentionsObj = this._mentions = {} + if (Array.isArray(mentions)) mentions.forEach(function (link) { + if (link && link.name) mentionsObj['@' + link.name] = link.link + }) + var out = marked((text || '').toString(), this.markedOpts) + delete this._mentions + return out +} + +Render.prototype.imageUrl = function (ref) { + return this.opts.img_base + ref +} + +Render.prototype.toUrl = function (href) { + if (!href) return href + var mentions = this._mentions + if (mentions && href in this._mentions) href = this._mentions[href] + switch (href[0]) { + case '%': return this.opts.base + encodeURIComponent(href) + case '@': + if (!u.isRef(href)) return false + return this.opts.base + href + case '&': return this.opts.blob_base + href + case '#': return this.opts.base + encodeURIComponent(href) + case '/': return this.opts.base + href.substr(1) + } + if (/^javascript:/.test(href)) return false + return href +} + +Render.prototype.lockIcon = function () { + return this.emoji('lock') +} + +Render.prototype.avatarImage = function (link, cb) { + var self = this + if (!link) return cb(), '' + if (typeof link === 'string') link = {link: link} + var img = h('img.ssb-avatar-image', { + alt: link.link + }) + if (link.image) gotAbout(null, link) + else self.app.getAbout(link.link, gotAbout) + function gotAbout(err, about) { + if (err) return cb(err) + if (!about.image) img.src = self.toUrl('/static/fallback.png') + else img.src = self.imageUrl(about.image) + cb() + } + return img +} + +Render.prototype.prepareLink = function (link, cb) { + if (typeof link === 'string') link = {link: link} + if (link.name || !link.link) cb(null, link) + else this.app.getAbout(link.link, function (err, about) { + if (err) return cb(err) + link.name = about.name + if (link.name && link.name[0] === link.link[0]) { + link.name = link.name.substr(1) + } + cb(null, link) + }) +} + +Render.prototype.prepareLinks = function (links, cb) { + var self = this + if (!links) return cb() + var done = multicb({pluck: 1}) + if (Array.isArray(links)) links.forEach(function (link) { + self.prepareLink(link, done()) + }) + done(cb) +} + +Render.prototype.idLink = function (link, cb) { + var self = this + if (!link) return cb(), '' + var a = h('a', ' ') + self.prepareLink(link, function (err, link) { + if (err) return cb(err) + a.href = self.toUrl(link.link) + a.childNodes[0].textContent = '@' + link.name + cb() + }) + return a +} + +Render.prototype.privateLine = function (recps, cb) { + var done = multicb({pluck: 1, spread: true}) + var self = this + var el = h('div.recps', + self.lockIcon(), + Array.isArray(recps) + ? recps.map(function (recp) { + return [' ', self.idLink(recp, done())] + }) : '') + done(cb) + return el +} + +Render.prototype.publish = function (content, cb) { + var self = this + + var el = h('div') + self.app.publish(content, function (err, msg) { + if (err) return el.appendChild(u.renderError(err)), cb() + self.app.unboxMsg(msg, function (err, msg) { + if (err) return el.appendChild(u.renderError(err)), cb() + self.renderMsg(msg, false, function (err, msgEl) { + if (err) msgEl = [ + h('a', {href: self.toUrl(msg.key)}, msg.key), + u.renderError(err)] + el.appendChild(h('div', + 'published:', + h('table.ssb-msgs', msgEl) + )) + cb() + }) + }) + }) + return el +} + +Render.prototype.renderMsg = function (msg, raw, cb) { + new RenderMsg(this, this.app, msg).message(raw, cb) +} + +Render.prototype.renderFeeds = function (raw) { + var self = this + return paramap(function (msg, cb) { + self.renderMsg(msg, raw, cb) + }, 4) +} diff --git a/lib/serve.js b/lib/serve.js new file mode 100644 index 0000000..e85ca31 --- /dev/null +++ b/lib/serve.js @@ -0,0 +1,774 @@ +var fs = require('fs') +var qs = require('querystring') +var pull = require('pull-stream') +var path = require('path') +var paramap = require('pull-paramap') +var sort = require('ssb-sort') +var crypto = require('crypto') +var toPull = require('stream-to-pull-stream') +var serveEmoji = require('emoji-server')() +var u = require('./util') +var cat = require('pull-cat') +var h = require('hyperscript') +var paginate = require('pull-paginate') +var ssbMentions = require('ssb-mentions') +var multicb = require('multicb') +var pkg = require('../package') + +module.exports = Serve + +var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') +var appHash = hash([fs.readFileSync(__filename)]) + +var urlIdRegex = /^(?:\/(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/ + +function hash(arr) { + return arr.reduce(function (hash, item) { + return hash.update(String(item)) + }, crypto.createHash('sha256')).digest('base64') +} + +function isMsgReadable(msg) { + var c = msg && msg.value.content + return typeof c === 'object' && c !== null +} + +function isMsgEncrypted(msg) { + var c = msg && msg.value.content + return typeof c === 'string' +} + +function ctype(name) { + switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { + case 'html': return 'text/html' + case 'js': return 'text/javascript' + case 'css': return 'text/css' + case 'png': return 'image/png' + case 'json': return 'application/json' + } +} + +function Serve(app, req, res) { + this.app = app + this.req = req + this.res = res + this.startDate = new Date() +} + +Serve.prototype.go = function () { + console.log(this.req.method, this.req.url) + var self = this + + if (this.req.method === 'POST' || this.req.method === 'PUT') { + pull( + toPull(this.req), + pull.collect(function (err, bufs) { + var data + if (!err) try { + data = qs.parse(Buffer.concat(bufs).toString('ascii')) + } catch(e) { + err = e + } + gotData(err, data) + }) + ) + } else { + gotData(null, {}) + } + function gotData(err, data) { + if (err) { + self.req.writeHead(400, {'Content-Type': 'text/plain'}) + self.req.end(err.stack) + } else { + self.data = data + self.handle() + } + } +} + +Serve.prototype.handle = function () { + var m = urlIdRegex.exec(this.req.url) + this.query = m[5] ? qs.parse(m[5]) : {} + switch (m[2]) { + case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1]) + case '%': return this.id(m[1], m[3]) + case '@': return this.userFeed(m[1], m[3]) + case '&': return this.blob(m[1]) + default: return this.path(m[4]) + } +} + +Serve.prototype.respond = function (status, message) { + this.res.writeHead(status) + this.res.end(message) +} + +Serve.prototype.respondSink = function (status, headers, cb) { + var self = this + self.res.writeHead(status, headers) + return toPull(self.res, cb || function (err) { + if (err) self.error(err) + }) +} + +Serve.prototype.path = function (url) { + var m + switch (url) { + case '/': return this.home() + case '/robots.txt': return this.res.end('User-agent: *') + } + if (m = /^\/%23(.*)/.exec(url)) { + return this.channel(decodeURIComponent(m[1])) + } + m = /^([^.]*)(?:\.(.*))?$/.exec(url) + switch (m[1]) { + case '/public': return this.public(m[2]) + case '/private': return this.private(m[2]) + case '/search': return this.search(m[2]) + case '/vote': return this.vote(m[2]) + } + m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) + switch (m[1]) { + case '/static': return this.static(m[2]) + case '/emoji': return this.emoji(m[2]) + } + return this.respond(404, 'Not found') +} + +Serve.prototype.home = function () { + pull( + pull.empty(), + this.wrapPage('/'), + this.respondSink(200, { + 'Content-Type': 'text/html' + }) + ) +} + +Serve.prototype.public = function (ext) { + var q = this.query + var opts = { + reverse: !q.forwards, + lt: Number(q.lt) || Date.now(), + gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 12 + } + + pull( + this.app.sbot.createLogStream(opts), + this.renderThreadPaginated(opts, null, q), + this.wrapMessages(), + this.wrapPublic(), + this.wrapPage('public'), + this.respondSink(200, { + 'Content-Type': ctype(ext) + }) + ) +} + +Serve.prototype.private = function (ext) { + var q = this.query + var opts = { + reverse: !q.forwards, + lt: Number(q.lt) || Date.now(), + gt: Number(q.gt) || -Infinity, + } + var limit = Number(q.limit) || 12 + + pull( + this.app.sbot.createLogStream(opts), + pull.filter(isMsgEncrypted), + paramap(this.app.unboxMsg, 4), + pull.filter(isMsgReadable), + pull.take(limit), + this.renderThreadPaginated(opts, null, q), + this.wrapMessages(), + this.wrapPrivate(opts), + this.wrapPage('private'), + this.respondSink(200, { + 'Content-Type': ctype(ext) + }) + ) +} + +Serve.prototype.search = function (ext) { + var searchQ = (this.query.q || '').trim() + var self = this + + if (/^ssb:\/\//.test(searchQ)) { + var maybeId = searchQ.substr(6) + if (u.isRef(maybeId)) searchQ = maybeId + } + + if (u.isRef(searchQ)) { + self.res.writeHead(302, { + Location: self.app.render.toUrl(searchQ) + }) + return self.res.end() + } + + pull( + self.app.search(searchQ), + self.renderThread(), + self.wrapMessages(), + self.wrapPage('search · ' + searchQ, searchQ), + self.respondSink(200, { + 'Content-Type': ctype(ext), + }) + ) +} + +Serve.prototype.vote = function (ext) { + var self = this + + var content = { + type: 'vote', + vote: { + link: self.data.link, + value: self.data.value, + expression: self.data.expression, + } + } + if (self.data.recps) content.recps = self.data.recps.split(',') + self.app.publish(content, function (err, msg) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage(content.vote.expression), + self.respondSink(500, { + 'Content-Type': ctype(ext) + }) + ) + + pull( + pull.once(msg), + pull.asyncMap(self.app.unboxMsg), + self.app.render.renderFeeds(false), + pull.map(u.toHTML), + self.wrapMessages(), + u.hyperwrap(function (content, cb) { + cb(null, h('div', + 'published:', + content + )) + }), + self.wrapPage('published'), + self.respondSink(302, { + 'Content-Type': ctype(ext), + 'Location': self.app.render.toUrl(msg.key) + }) + ) + }) +} + +Serve.prototype.rawId = function (id) { + var self = this + var etag = hash([id, appHash, 'raw']) + if (self.req.headers['if-none-match'] === etag) return self.respond(304) + + self.app.getMsgDecrypted(id, function (err, msg) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.respondSink(400, {'Content-Type': ctype('html')}) + ) + return pull( + pull.once(msg), + self.renderRawMsgPage(id), + self.respondSink(200, { + 'Content-Type': ctype('html'), + 'etag': etag + }) + ) + }) +} + +Serve.prototype.channel = function (channel) { + var q = this.query + var gt = Number(q.gt) || -Infinity + var lt = Number(q.lt) || Date.now() + var opts = { + reverse: !q.forwards, + lt: lt, + gt: gt, + limit: Number(q.limit) || 12, + query: [{$filter: { + value: {content: {channel: channel}}, + timestamp: { + $gt: gt, + $lt: lt, + } + }}] + } + + pull( + this.app.sbot.query.read(opts), + this.renderThreadPaginated(opts, null, q), + this.wrapMessages(), + this.wrapChannel(channel), + this.wrapPage('#' + channel), + this.respondSink(200, { + 'Content-Type': ctype('html') + }) + ) +} + +function threadHeads(msgs, rootId) { + return sort.heads(msgs.filter(function (msg) { + return msg.value.content.root === rootId + || msg.key === rootId + })) +} + + +Serve.prototype.id = function (id, ext) { + var self = this + if (self.query.raw != null) return self.rawId(id) + + this.app.getMsgDecrypted(id, function (err, rootMsg) { + var getRoot = err ? pull.error(err) : pull.once(rootMsg) + var recps = rootMsg && rootMsg.value.content.recps + var threadRootId = rootMsg && rootMsg.value.content.root || id + var channel = rootMsg && rootMsg.value.content.channel + + pull( + cat([getRoot, self.app.sbot.links({dest: id, values: true})]), + pull.unique('key'), + paramap(self.app.unboxMsg, 4), + pull.collect(function (err, links) { + if (err) return self.respond(500, err.stack || err) + var etag = hash(sort.heads(links).concat(appHash, ext, qs)) + if (self.req.headers['if-none-match'] === etag) return self.respond(304) + pull( + pull.values(sort(links)), + self.renderThread(), + self.wrapMessages(), + self.wrapThread({ + recps: recps, + root: threadRootId, + branches: id === threadRootId ? threadHeads(links, id) : id, + channel: channel, + }), + self.wrapPage(id), + self.respondSink(200, { + 'Content-Type': ctype(ext), + 'etag': etag + }) + ) + }) + ) + }) +} + +Serve.prototype.userFeed = function (id, ext) { + var self = this + var q = self.query + var opts = { + id: id, + reverse: !q.forwards, + lt: Number(q.lt) || Date.now(), + gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 20 + } + + self.app.getAbout(id, function (err, about) { + if (err) self.app.error(err) + pull( + self.app.sbot.createUserStream(opts), + self.renderThreadPaginated(opts, id, q), + self.wrapMessages(), + self.wrapUserFeed(id), + self.wrapPage(about.name), + self.respondSink(200, { + 'Content-Type': ctype(ext) + }) + ) + }) +} + +Serve.prototype.file = function (file) { + var self = this + fs.stat(file, function (err, stat) { + if (err && err.code === 'ENOENT') return self.respond(404, 'Not found') + if (err) return self.respond(500, err.stack || err) + if (!stat.isFile()) return self.respond(403, 'May only load files') + if (self.ifModified(stat.mtime)) return self.respond(304, 'Not modified') + self.res.writeHead(200, { + 'Content-Type': ctype(file), + 'Content-Length': stat.size, + 'Last-Modified': stat.mtime.toGMTString() + }) + fs.createReadStream(file).pipe(self.res) + }) +} + +Serve.prototype.static = function (file) { + this.file(path.join(__dirname, '../static', file)) +} + +Serve.prototype.emoji = function (emoji) { + serveEmoji(this.req, this.res, emoji) +} + +Serve.prototype.blob = function (id) { + var self = this + var blobs = self.app.sbot.blobs + if (self.req.headers['if-none-match'] === id) return self.respond(304) + blobs.has(id, function (err, has) { + if (err) { + if (/^invalid/.test(err.message)) return self.respond(400, err.message) + else return self.respond(500, err.message || err) + } + if (!has) return self.respond(404, 'Not found') + pull( + blobs.get(id), + pull.map(Buffer), + self.respondSink(200, { + 'Cache-Control': 'public, max-age=315360000', + 'etag': id + }) + ) + }) +} + +Serve.prototype.ifModified = function (lastMod) { + var ifModSince = this.req.headers['if-modified-since'] + if (!ifModSince) return false + var d = new Date(ifModSince) + return d && Math.floor(d/1000) >= Math.floor(lastMod/1000) +} + +Serve.prototype.wrapMessages = function () { + return u.hyperwrap(function (content, cb) { + cb(null, h('table.ssb-msgs', content)) + }) +} + +Serve.prototype.renderThread = function () { + return pull( + this.app.render.renderFeeds(false), + pull.map(u.toHTML) + ) +} + +function mergeOpts(a, b) { + var obj = {}, k + for (k in a) { + obj[k] = a[k] + } + for (k in b) { + if (b[k] != null) obj[k] = b[k] + else delete obj[k] + } + return obj +} + +Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { + function link(opts, name, cb) { + cb(null, h('tr', h('td.paginate', {colspan: 2}, + h('a', {href: '?' + qs.stringify(mergeOpts(q, opts))}, name)))) + } + return pull( + paginate( + function onFirst(msg, cb) { + var num = feedId ? msg.value.sequence : msg.timestamp + if (q.forwards) { + link({ + lt: num, + gt: null, + forwards: null, + }, '↓ older', cb) + } else { + link({ + lt: null, + gt: num, + forwards: 1, + }, '↑ newer', cb) + } + }, + this.app.render.renderFeeds(), + function onLast(msg, cb) { + var num = feedId ? msg.value.sequence : msg.timestamp + if (q.forwards) { + link({ + lt: null, + gt: num, + forwards: 1, + }, '↑ newer', cb) + } else { + link({ + lt: num, + gt: null, + forwards: null, + }, '↓ older', cb) + } + }, + function onEmpty(cb) { + if (q.forwards) { + link({ + gt: null, + lt: opts.gt + 1, + forwards: null, + }, '↓ older', cb) + } else { + link({ + gt: opts.lt - 1, + lt: null, + forwards: 1, + }, '↑ newer', cb) + } + } + ), + pull.map(u.toHTML) + ) +} + +Serve.prototype.renderRawMsgPage = function (id) { + return pull( + this.app.render.renderFeeds(true), + pull.map(u.toHTML), + this.wrapMessages(), + this.wrapPage(id) + ) +} + +function catchHTMLError() { + return function (read) { + var ended + return function (abort, cb) { + if (ended) return cb(ended) + read(abort, function (end, data) { + if (!end || end === true) return cb(end, data) + ended = true + cb(null, u.renderError(end).outerHTML) + }) + } + } +} + +function styles() { + return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8') +} + +Serve.prototype.appendFooter = function () { + var self = this + return function (read) { + return cat([read, u.readNext(function (cb) { + var ms = new Date() - self.startDate + cb(null, pull.once(h('footer', + h('a', {href: pkg.homepage}, pkg.name), ' · ', + ms/1000 + 's' + ).outerHTML)) + })]) + } +} + +Serve.prototype.wrapPage = function (title, searchQ) { + var self = this + return pull( + catchHTMLError(), + self.appendFooter(), + u.hyperwrap(function (content, cb) { + var done = multicb({pluck: 1, spread: true}) + done()(null, h('html', h('head', + h('meta', {charset: 'utf-8'}), + h('title', title), + h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}), + h('style', styles()) + ), + h('body', + h('nav.nav-bar', h('form', {action: '/search', method: 'get'}, + h('a', {href: '/public'}, 'public'), ' ', + h('a', {href: '/private'}, 'private') , ' ', + self.app.render.idLink(self.app.sbot.id, done()), ' ', + h('input.search-input', {name: 'q', value: searchQ, + placeholder: 'search', size: 16}) + // h('a', {href: '/convos'}, 'convos'), ' ', + // h('a', {href: '/friends'}, 'friends'), ' ', + // h('a', {href: '/git'}, 'git') + )), + content + ))) + done(cb) + }) + ) +} + +Serve.prototype.wrapUserFeed = function (id) { + var self = this + return u.hyperwrap(function (thread, cb) { + var done = multicb({pluck: 1, spread: true}) + done()(null, [ + h('section.ssb-feed', + h('h3.feed-name', + self.app.render.avatarImage(id, done()), ' ', + h('strong', self.app.render.idLink(id, done())) + ), + h('code', h('small', id)) + ), + thread + ]) + done(cb) + }) +} + +Serve.prototype.wrapPublic = function (opts) { + var self = this + return u.hyperwrap(function (thread, cb) { + self.composer(null, function (err, composer) { + if (err) return cb(err) + cb(null, [ + composer, + thread + ]) + }) + }) +} + +Serve.prototype.wrapPrivate = function (opts) { + var self = this + return u.hyperwrap(function (thread, cb) { + self.composer({ + placeholder: 'private message', + useRecpsFromMentions: true, + }, function (err, composer) { + if (err) return cb(err) + cb(null, [ + composer, + thread + ]) + }) + }) +} + +Serve.prototype.wrapThread = function (opts) { + var self = this + return u.hyperwrap(function (thread, cb) { + self.app.render.prepareLinks(opts.recps, function (err, recps) { + if (err) return cb(er) + self.composer({ + placeholder: recps ? 'private reply' : 'reply', + id: 'reply', + root: opts.root, + channel: opts.channel, + branches: opts.branches, + recps: recps, + }, function (err, composer) { + if (err) return cb(err) + cb(null, [ + thread, + composer + ]) + }) + }) + }) +} + +Serve.prototype.wrapChannel = function (channel) { + var self = this + return u.hyperwrap(function (thread, cb) { + self.composer({ + placeholder: 'public message in #' + channel, + channel: channel, + }, function (err, composer) { + if (err) return cb(err) + cb(null, [ + h('section', + h('h3.feed-name', + h('a', {href: self.app.render.toUrl('#' + channel)}, '#' + channel) + ) + ), + composer, + thread + ]) + }) + }) +} + +Serve.prototype.composer = function (opts, cb) { + var self = this + opts = opts || {} + var data = self.data + + var done = multicb({pluck: 1, spread: true}) + done()(null, h('section.composer', + h('form', {method: 'post', action: opts.id ? '#' + opts.id : ''}, + opts.recps ? self.app.render.privateLine(opts.recps, done()) : '', + h('textarea', { + id: opts.id, + name: 'text', + rows: 4, + cols: 70, + placeholder: opts.placeholder || 'public message', + }, data.text || ''), + h('div.composer-actions', + h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ', + h('input', {type: 'submit', name: 'action', value: 'preview'})), + data.action === 'preview' ? preview(false, done()) : + data.action === 'raw' ? preview(true, done()) : + data.action === 'publish' ? publish(done()) : '' + ) + )) + done(cb) + + function preview(raw, cb) { + var myId = self.app.sbot.id + var content + try { + content = JSON.parse(data.text) + } catch (err) { + content = { + type: 'post', + text: data.text, + } + var mentions = ssbMentions(data.text) + if (mentions.length) content.mentions = mentions + if (opts.useRecpsFromMentions) { + content.recps = [myId].concat(mentions.filter(function (e) { + return e.link[0] === '@' + })) + if (opts.recps) return cb(new Error('got recps in opts and mentions')) + } else { + if (opts.recps) content.recps = opts.recps + } + if (opts.root) content.root = opts.root + if (opts.branches) content.branch = u.fromArray(opts.branches) + if (opts.channel) content.channel = opts.channel + } + var msg = { + value: { + author: myId, + timestamp: Date.now(), + content: content + } + } + if (content.recps) msg.value.private = true + var msgContainer = h('table.ssb-msgs') + pull( + pull.once(msg), + pull.asyncMap(self.app.unboxMsg), + self.app.render.renderFeeds(raw), + pull.drain(function (el) { + msgContainer.appendChild(el) + }, cb) + ) + return h('form', {method: 'post', action: '#reply'}, + h('input', {type: 'hidden', name: 'content', + value: JSON.stringify(content)}), + h('div', h('em', 'draft:')), + msgContainer, + h('div.composer-actions', + h('input', {type: 'submit', name: 'action', value: 'publish'}) + ) + ) + } + + function publish(cb) { + var content + try { + content = JSON.parse(self.data.content) + } catch(e) { + return cb(), u.renderError(e) + } + return self.app.render.publish(content, cb) + } + +} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..d6c3133 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,80 @@ +var pull = require('pull-stream') +var cat = require('pull-cat') +var h = require('hyperscript') +var u = exports + +u.ssbRefRegex = /((?:@|%|&)[A-Za-z0-9\/+]{43}=\.[\w\d]+)/g + +u.isRef = function (str) { + u.ssbRefRegex.lastIndex = 0 + return u.ssbRefRegex.test(str) +} + +u.readNext = function (fn) { + var next + return function (end, cb) { + if (next) return next(end, cb) + fn(function (err, _next) { + if (err) return cb(err) + next = _next + next(null, cb) + }) + } +} + +u.pullReverse = function () { + return function (read) { + return u.readNext(function (cb) { + pull(read, pull.collect(function (err, items) { + cb(err, items && pull.values(items.reverse())) + })) + }) + } +} + +u.toHTML = function (el) { + if (!el) return '' + if (typeof el === 'string' || Array.isArray(el)) { + return h('div', el).innerHTML + } + var html = el.outerHTML || String(el) + if (el.nodeName === 'html') html = '<!doctype html>' + html + '\n' + return html +} + +u.hyperwrap = function (fn) { + var token = '__HYPERWRAP_' + Math.random() + '__' + return function (read) { + return u.readNext(function (cb) { + fn(token, function (err, el) { + if (err) return cb(err) + var parts = u.toHTML(el).split(token) + switch (parts.length) { + case 0: return cb(null, pull.empty()) + case 1: return cb(null, pull.once(parts[0])) + case 2: return cb(null, + cat([pull.once(parts[0]), read, pull.once(parts[1])])) + default: return cb(new Error('duplicate wrap')) + } + }) + }) + } +} + +u.linkDest = function (link) { + return typeof link === 'string' ? link : link && link.link || link +} + +u.toArray = function (x) { + return !x ? [] : Array.isArray(x) ? x : [x] +} + +u.fromArray = function (arr) { + return Array.isArray(arr) && arr.length === 1 ? arr[0] : arr +} + +u.renderError = function(err) { + return h('div.error', + h('h3', err.name), + h('pre', err.stack)) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8b769eb --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "patchfoo", + "version": "0.0.0", + "description": "plain ssb web ui", + "dependencies": { + "asyncmemo": "^1.0.0", + "emoji-named-characters": "^1.0.2", + "emoji-server": "^1.0.0", + "human-time": "^0.0.1", + "hyperscript": "^2.0.2", + "lrucache": "^1.0.2", + "multicb": "^1.2.1", + "pull-cat": "^1.1.11", + "pull-paginate": "^1.0.0", + "pull-paramap": "^1.2.1", + "pull-stream": "^3.5.0", + "ssb-avatar": "^0.2.0", + "ssb-marked": "^0.6.0", + "ssb-mentions": "^0.1.0", + "ssb-sort": "^1.0.0", + "stream-to-pull-stream": "^1.7.2" + }, + "devDependencies": { + "ssb-client": "^4.4.0" + }, + "author": "cel", + "homepage": "https://git.scuttlebot.io/%25YAg1hicat%2B2GELjE2QJzDwlAWcx0ML%2B1sXEdsWwvdt8%3D.sha256", + "repository": { + "type": "git", + "url": "ssb://%YAg1hicat+2GELjE2QJzDwlAWcx0ML+1sXEdsWwvdt8=.sha256" + }, + "license": "AGPL-3.0+" +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..66f503f --- /dev/null +++ b/server.js @@ -0,0 +1,4 @@ +require('ssb-client')(function (err, sbot, config) { + if (err) throw err + require('.').init(sbot, config) +}) diff --git a/static/fallback.png b/static/fallback.png Binary files differnew file mode 100644 index 0000000..ceb8d81 --- /dev/null +++ b/static/fallback.png diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..5cfcab5 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,112 @@ +body { + margin: 0 auto; + font-family: sans-serif; +} + +section { + padding: 1ex; +} + +.composer-actions { + text-align: right; +} + +.ssb-post img { + max-width: 100%; +} + +.nav-bar { + background-color: #039; + padding-left: 1em; +} + +.nav-bar a { + color: #ed0; +} + +.search-input { + color: #efd; + background-color: transparent; + border: 1px solid black; + font-size: inherit; +} + +.paginate { + padding-left: 1ex; +} + +footer { + font-size: smaller; + text-align: center; + color: #999; + padding: 1ex; +} + +.ssb-avatar-image { + width: 2em; + height: 2em; + vertical-align: top; +} + +.ssb-feed .ssb-avatar-image { + width: 4em; + height: 4em; +} + +h3.feed-name { + padding: 0; + margin: 0; +} + +textarea { + font: inherit; + width: 100%; +} + +pre { + white-space: pre-wrap; +} + +.ssb-id { + font-size: .7em; + color: #777; +} + +.ssb-timestamp { + font-size: smaller; +} + +.msg-header { + margin-bottom: .25ex; +} + +.recps { + font-size: smaller; +} + +.ssb-msgs { + border-spacing: 0; + border-bottom: 1px solid #ddd; + width: 100%; +} + +.msg-left, +.msg-main, +.msg-right { + border-top: 1px solid #ddd; + vertical-align: top; + padding: .5ex; +} + +.msg-main { + width: 100%; +} + +.feed-name { + padding: .75ex; +} + +.msg-left .ssb-avatar-image { + margin-left: .25em; + background-color: #efd; +} |