├── .github ├── screenshots │ ├── sampleEmail.png │ └── webUI.png └── workflows │ └── docker-image.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assets ├── logo_dark.png ├── logo_light.png └── logo_light_square.png ├── go.mod ├── go.sum ├── setVersionNumber.sh └── walrss ├── internal ├── core │ ├── feedItems.go │ ├── feeds.go │ ├── opml │ │ └── opml.go │ ├── sessions.go │ ├── userError.go │ ├── users.go │ ├── util.go │ ├── validation.go │ └── version.go ├── db │ ├── 00000000000001_initialise.up.sql │ ├── 20230119135807_etags.up.sql │ ├── 20230119165107_lastmodified.up.sql │ ├── 20250209222240_lastfetched.up.sql │ ├── 20250209224630_feeditems.up.sql │ ├── db.go │ ├── migrations.go │ └── sendDay.go ├── http │ ├── auth.go │ ├── edit.go │ ├── exportImport.go │ ├── http.go │ ├── mainpage.go │ ├── new.go │ ├── testEmail.go │ └── views │ │ ├── layoutComponents.qtpl.html │ │ ├── layoutComponents.qtpl.html.go │ │ ├── main.qtpl.html │ │ ├── main.qtpl.html.go │ │ ├── page.qtpl.html │ │ ├── page.qtpl.html.go │ │ ├── register.qtpl.html │ │ ├── register.qtpl.html.go │ │ ├── signin.qtpl.html │ │ ├── signin.qtpl.html.go │ │ └── views.go ├── rss │ ├── processor.go │ └── watcher.go ├── state │ └── state.go ├── static │ ├── assets │ │ ├── logo_dark.png │ │ └── logo_light.png │ └── static.go └── urls │ └── urls.go └── main.go /.github/screenshots/sampleEmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/.github/screenshots/sampleEmail.png -------------------------------------------------------------------------------- /.github/screenshots/webUI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/.github/screenshots/webUI.png -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | IMAGE_NAME: "ghcr.io/codemicro/walrss" 9 | 10 | jobs: 11 | 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Get tag version 20 | id: get_version 21 | run: echo "::set-output name=VERSION::$(echo ${GITHUB_REF/refs\/tags\//} | sed "s/^v//")" 22 | 23 | - name: Make image tags 24 | id: make_tags 25 | run: | 26 | echo "::set-output name=LATEST::$IMAGE_NAME:latest" 27 | echo "::set-output name=VERSIONED::$IMAGE_NAME:${{ steps.get_version.outputs.VERSION }}" 28 | 29 | - name: Set version number 30 | run: bash setVersionNumber.sh "${{ steps.get_version.outputs.VERSION }}" 31 | 32 | - name: Build Docker image 33 | run: docker build . --file Dockerfile --tag ${{ steps.make_tags.outputs.LATEST }} --tag ${{ steps.make_tags.outputs.VERSIONED }} 34 | 35 | - name: Login to ghcr.io 36 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io --username codemicro --password-stdin 37 | 38 | - name: Push image 39 | run: docker push --all-tags $IMAGE_NAME 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### Go ### 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | ### Go Patch ### 20 | /vendor/ 21 | /Godeps/ 22 | 23 | ### ----------------- 24 | 25 | 26 | run/ 27 | bin/ 28 | walrss/internal/state/version.go 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Unreleased 8 | ### Fixed 9 | * Do not mistakenly transform unsafe characters into JSON notation when sending from the server as pre-set form values 10 | 11 | ## 0.4.4 - 2025-02-11 12 | ### Fixed 13 | * Flip inverted boolean check that filtered out unseen articles instead of seen articles 14 | 15 | ## 0.4.3 - 2025-02-11 16 | ### Changed 17 | * When no feed items are stored in the database for a particular feed, fall back to using the old time-based detection method 18 | * Do not index feed items at addition time 19 | * Do not update feed item index when sending a test email to prevent items being missed from the main scheduled emails 20 | * Use batch inserts when importing from OPML 21 | 22 | ## 0.4.2 - 2025-02-09 23 | ### Fixed 24 | * Prevent nil dereference when feeds without published dates are processed 25 | 26 | ## 0.4.1 - 2025-02-09 27 | ### Changed 28 | * Feed fetching will reuse cached content within an hour of a previous fetch without checking for a HTTP 304 (Not Modified) from the remote resource 29 | * Detect new feed items by checking against a stored list of all known items for that feed 30 | 31 | ## 0.4.0 - 2025-02-09 32 | ### Changed 33 | * Make including contact information in the user agent optional 34 | * Support selecting a TLS mode for email (STARTTLS, TLS or none) 35 | * Support not using a username/password for email authentication 36 | 37 | ## 0.3.8 - 2025-01-18 38 | ### Fixed 39 | * Cached feed content and corresponding etags/last modified headers are now cleared when the URL of a feed entry is updated 40 | 41 | ## 0.3.7 - 2023-04-08 42 | ### Fixed 43 | * Remove potential race condition caused by using `RLock` instead of `Lock` 44 | 45 | ## 0.3.6 - 2023-02-25 46 | ### Changed 47 | * Updated Go build version 48 | ### Fixed 49 | * Multiple security advisories 50 | 51 | ## 0.3.5 - 2022-01-19 52 | ### Added 53 | * Added space for contact information to user agent 54 | 55 | ## 0.3.4 - 2022-01-19 56 | ### Added 57 | * Support for `ETag` and `Last-Modified` headers in feed responses 58 | ### Changed 59 | * Added version number to email footer 60 | 61 | ## 0.3.3 - 2022-08-31 62 | ### Fixed 63 | * Feed entries can now be deleted. [#1](https://github.com/codemicro/walrss/issues/1) 64 | * Proper errors are shown when attempting to register with an in-use email address. [#2](https://github.com/codemicro/walrss/issues/2) 65 | 66 | ## 0.3.2 - 2022-08-13 67 | ### Added 68 | * OIDC support 69 | 70 | ## 0.3.1 - 2022-08-13 71 | This one deploys. 72 | 73 | ## 0.3.0 - 2022-08-13 74 | ### Changed 75 | * Migrated to SQLite in favour of BoltDB. This a completely breaking change. 76 | 77 | ## 0.2.3 - 2022-06-01 78 | ### Added 79 | * Added a user agent to any requests made by Walrss (a very basic regex for this is `walrss(\/(\d|\.){5})? \(https:\/\/github\.com\/codemicro\/walrss\)`) 80 | 81 | ## 0.2.2 - 2022-05-08 82 | ### Fixed 83 | * Feed entries from midnight on a given day are no longer mistakenly ignored. 84 | 85 | ## 0.2.1 - 2022-04-29 86 | ### Fixed 87 | * Digest emails no longer contain three extra days worth of feed items 88 | 89 | ## 0.2.0 - 2022-04-29 90 | ### Added 91 | * Progress display for test emails 92 | 93 | ## 0.1.1 - 2022-04-16 94 | ### Added 95 | * Support for OPML imports and exports 96 | 97 | ### Fixed 98 | * Secure cookies are no longer sent when debug mode is enabled 99 | 100 | ## 0.1.0 - 2022-04-14 101 | Initial release 102 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1 as builder 2 | 3 | RUN mkdir /build 4 | ADD . /build/ 5 | WORKDIR /build 6 | # Go 1.18 introduced a compile-time dependency for Git unless `-buildvcs=false` is provided 7 | RUN CGO_ENABLED=1 GOOS=linux go build -a -buildvcs=false -installsuffix cgo -ldflags '-extldflags "-static" -s -w' -o main github.com/codemicro/walrss/walrss 8 | 9 | FROM alpine 10 | COPY --from=builder /build/main / 11 | WORKDIR /run 12 | 13 | ENV WALRSS_DIR /run 14 | CMD ["../main"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dockerBuild 2 | 3 | build: templates 4 | mkdir -p bin 5 | go build -o bin/walrss github.com/codemicro/walrss/walrss 6 | 7 | run: build 8 | mkdir -p run 9 | cd run && ../bin/walrss 10 | 11 | fmt: 12 | go fmt github.com/codemicro/walrss/... 13 | 14 | templates: 15 | qtc -skipLineComments -ext qtpl.html -dir walrss/internal/http/views 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # walrss 2 | 3 | *Email-based RSS digest generator* 4 | 5 | [![RSS is cool](https://img.shields.io/badge/rss-is%20cool-orange?logo=rss)](https://github.com/codemicro/walrss/releases.atom) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/codemicro/walrss) [![Go Report Card](https://goreportcard.com/badge/github.com/codemicro/walrss)](https://goreportcard.com/report/github.com/codemicro/walrss) ![Lines of code](https://img.shields.io/tokei/lines/github/codemicro/walrss) 6 | 7 | --- 8 | 9 | | Email | Web UI | 10 | |------------------------------------------------------|------------------------------------------| 11 | | ![Sample email](.github/screenshots/sampleEmail.png) | ![Web UI](.github/screenshots/webUI.png) | 12 | 13 | ## Features 14 | 15 | * Multi-user support 16 | * Option to choose between daily or weekly digests that are sent at specific times of day 17 | * OPML import/export 18 | * [OIDC](https://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)) integration 19 | 20 | ## Quickstart 21 | 22 | ### Docker 23 | 24 | ```bash 25 | docker pull ghcr.io/codemicro/walrss:latest 26 | mkdir walrss-data 27 | nano walrss-data/config.yaml # You'll have to fill in the required fields detailed below 28 | docker run -d -v $(pwd)/walrss-data:/run -p 8080:8080 ghcr.io/codemicro/walrss:latest 29 | ``` 30 | 31 | ### Build from source 32 | 33 | You must have an up-to-date version of the Go toolchain installed. Check `go.mod` for the minimum required version. 34 | 35 | ```bash 36 | git clone https://github.com/codemicro/walrss.git; cd walrss 37 | go build -o walrssServer github.com/codemicro/walrss/walrss 38 | ``` 39 | 40 | ```bash 41 | mkdir walrss-data 42 | nano walrss-data/config.yaml # You'll have to fill in the required fields detailed below 43 | WALRSS_DIR="./walrss-data" ./walrssServer 44 | # now go to http://127.0.0.1:8080 45 | ``` 46 | 47 | ## Config 48 | 49 | Your `config.yaml` file lives in your data directory, which is specified by the `WALRSS_DIR` environment variable (default is `./` or `/run` if you're using Docker). Below is a list of the possible options you can set within it. 50 | 51 | ```yaml 52 | server: 53 | host: 127.0.0.1 54 | port: 8080 55 | # externalURL is the URL that your instance of Walrss is accessible at 56 | # This is a required field 57 | externalURL: "http://127.0.0.1:8080" 58 | email: 59 | # These are required fields 60 | host: "smtp.sendgrid.net" 61 | port: 587 62 | from: "Walrss " 63 | # These fields are not required 64 | tls: "" # valid values are: starttls, tls, no - default: starttls 65 | password: "yourapikey" 66 | username: "apikey" 67 | platform: 68 | disableRegistration: false 69 | disableSecureCookies: false 70 | contactInformation: "https://example.com" # optional. will be included in the user agent if set. 71 | oidc: 72 | enable: false 73 | clientID: "yourclientid" 74 | clientSecret: "yourclientsecret" 75 | issuer: "https://sso.example.com/" 76 | ``` 77 | 78 | ## Future features 79 | 80 | * Feed categories 81 | * Administrator users 82 | * Account controls (eg password reset, change password, etc) 83 | -------------------------------------------------------------------------------- /assets/logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/assets/logo_dark.png -------------------------------------------------------------------------------- /assets/logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/assets/logo_light.png -------------------------------------------------------------------------------- /assets/logo_light_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/assets/logo_light_square.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codemicro/walrss 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/bwmarrin/go-alone v0.0.0-20190806015146-742bb55d1631 7 | github.com/carlmjohnson/requests v0.22.3 8 | github.com/coreos/go-oidc v2.2.1+incompatible 9 | github.com/gofiber/fiber/v2 v2.31.0 10 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible 11 | github.com/kkyr/fig v0.3.0 12 | github.com/lithammer/shortuuid/v4 v4.0.0 13 | github.com/matcornic/hermes v1.2.0 14 | github.com/mattn/go-sqlite3 v1.14.14 15 | github.com/mmcdole/gofeed v1.1.3 16 | github.com/patrickmn/go-cache v2.1.0+incompatible 17 | github.com/rs/zerolog v1.26.1 18 | github.com/stevelacy/daz v0.1.4 19 | github.com/uptrace/bun v1.1.7 20 | github.com/uptrace/bun/dialect/sqlitedialect v1.1.7 21 | github.com/uptrace/bun/extra/bundebug v1.1.7 22 | github.com/valyala/fasthttp v1.34.0 23 | github.com/valyala/quicktemplate v1.7.0 24 | golang.org/x/crypto v0.1.0 25 | golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7 26 | ) 27 | 28 | require ( 29 | github.com/Masterminds/goutils v1.1.1 // indirect 30 | github.com/Masterminds/semver v1.5.0 // indirect 31 | github.com/Masterminds/sprig v2.22.0+incompatible // indirect 32 | github.com/PuerkitoBio/goquery v1.5.1 // indirect 33 | github.com/andybalholm/brotli v1.0.4 // indirect 34 | github.com/andybalholm/cascadia v1.1.0 // indirect 35 | github.com/fatih/color v1.13.0 // indirect 36 | github.com/golang/protobuf v1.5.2 // indirect 37 | github.com/google/uuid v1.3.0 // indirect 38 | github.com/huandu/xstrings v1.3.2 // indirect 39 | github.com/imdario/mergo v0.3.12 // indirect 40 | github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect 41 | github.com/jinzhu/inflection v1.0.0 // indirect 42 | github.com/json-iterator/go v1.1.10 // indirect 43 | github.com/klauspost/compress v1.15.0 // indirect 44 | github.com/mattn/go-colorable v0.1.12 // indirect 45 | github.com/mattn/go-isatty v0.0.14 // indirect 46 | github.com/mattn/go-runewidth v0.0.9 // indirect 47 | github.com/mitchellh/copystructure v1.2.0 // indirect 48 | github.com/mitchellh/mapstructure v1.4.1 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 50 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect 51 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect 52 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect 53 | github.com/olekukonko/tablewriter v0.0.5 // indirect 54 | github.com/pelletier/go-toml v1.9.3 // indirect 55 | github.com/pquerna/cachecontrol v0.1.0 // indirect 56 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 57 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 58 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect 59 | github.com/valyala/bytebufferpool v1.0.0 // indirect 60 | github.com/valyala/tcplisten v1.0.0 // indirect 61 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 62 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 63 | golang.org/x/net v0.7.0 // indirect 64 | golang.org/x/sys v0.5.0 // indirect 65 | golang.org/x/text v0.7.0 // indirect 66 | google.golang.org/appengine v1.6.7 // indirect 67 | google.golang.org/protobuf v1.28.0 // indirect 68 | gopkg.in/russross/blackfriday.v2 v2.0.0 // indirect 69 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 70 | gopkg.in/yaml.v2 v2.4.0 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 3 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 4 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 5 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 6 | github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= 7 | github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= 8 | github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= 9 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 10 | github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 11 | github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 12 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 13 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 14 | github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= 15 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 16 | github.com/bwmarrin/go-alone v0.0.0-20190806015146-742bb55d1631 h1:Xb5rra6jJt5Z1JsZhIMby+IP5T8aU+Uc2RC9RzSxs9g= 17 | github.com/bwmarrin/go-alone v0.0.0-20190806015146-742bb55d1631/go.mod h1:P86Dksd9km5HGX5UMIocXvX87sEp2xUARle3by+9JZ4= 18 | github.com/carlmjohnson/requests v0.22.3 h1:ip16AKXNYuArdw9L5/1mL+mNorlZO5XhkLg617yOumc= 19 | github.com/carlmjohnson/requests v0.22.3/go.mod h1:iTsaX9TdFg2+L4WtZO/HFyDMPEfBnogV3i4A4gjDnvs= 20 | github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= 21 | github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= 22 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 23 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 28 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 29 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 30 | github.com/gofiber/fiber/v2 v2.31.0 h1:M2rWPQbD5fDVAjcoOLjKRXTIlHesI5Eq7I5FEQPt4Ow= 31 | github.com/gofiber/fiber/v2 v2.31.0/go.mod h1:1Ega6O199a3Y7yDGuM9FyXDPYQfv+7/y48wl6WCwUF4= 32 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 34 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 35 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 36 | github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 37 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 38 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 39 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 40 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 41 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 42 | github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= 43 | github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 44 | github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= 45 | github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 46 | github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg= 47 | github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 48 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 49 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 50 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= 51 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= 52 | github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= 53 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 54 | github.com/kkyr/fig v0.3.0 h1:5bd1amYKp/gsK2bGEUJYzcCrQPKOZp6HZD9K21v9Guo= 55 | github.com/kkyr/fig v0.3.0/go.mod h1:fEnrLjwg/iwSr8ksJF4DxrDmCUir5CaVMLORGYMcz30= 56 | github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= 57 | github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 58 | github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= 59 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 60 | github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c= 61 | github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y= 62 | github.com/matcornic/hermes v1.2.0 h1:AuqZpYcTOtTB7cahdevLfnhIpfzmpqw5Czv8vpdnFDU= 63 | github.com/matcornic/hermes v1.2.0/go.mod h1:lujJomb016Xjv8wBnWlNvUdtmvowjjfkqri5J/+1hYc= 64 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 65 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 66 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 67 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 68 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 69 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 70 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 71 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 72 | github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= 73 | github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 74 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 75 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 76 | github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= 77 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 78 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 79 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 80 | github.com/mmcdole/gofeed v1.1.3 h1:pdrvMb18jMSLidGp8j0pLvc9IGziX4vbmvVqmLH6z8o= 81 | github.com/mmcdole/gofeed v1.1.3/go.mod h1:QQO3maftbOu+hiVOGOZDRLymqGQCos4zxbA4j89gMrE= 82 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI= 83 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8= 84 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 85 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 86 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 87 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 88 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 89 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 90 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 91 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 92 | github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= 93 | github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 94 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 95 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 96 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 97 | github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= 98 | github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= 99 | github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 100 | github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= 101 | github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= 102 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 103 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 104 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 105 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 106 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 107 | github.com/stevelacy/daz v0.1.4 h1:ugmff/D7D764wZjXSgSryEINE/bi+Xddllw3JQQGbWk= 108 | github.com/stevelacy/daz v0.1.4/go.mod h1:AbK6DzjiIL15r4bQtcFvOBAvDGMXoh+uIG26NRUugt0= 109 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 110 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 111 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 112 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 113 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= 114 | github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= 115 | github.com/uptrace/bun v1.1.7 h1:biOoh5dov69hQPBlaRsXSHoEOIEnCxFzQvUmbscSNJI= 116 | github.com/uptrace/bun v1.1.7/go.mod h1:Z2Pd3cRvNKbrYuL6Gp1XGjA9QEYz+rDz5KkEi9MZLnQ= 117 | github.com/uptrace/bun/dialect/sqlitedialect v1.1.7 h1:xxc1n1nUdn6zqY6ji1ZkiaHQyop8J237uRyptqXGW08= 118 | github.com/uptrace/bun/dialect/sqlitedialect v1.1.7/go.mod h1:GjqiPWAa9JCLlv51mB1rjk8QRgwv6HlQ+IAtyrobfAY= 119 | github.com/uptrace/bun/extra/bundebug v1.1.7 h1:YbW7i9pUfPJMzclSzdHslIvAAR0WO9dW34ctL1Xh+UM= 120 | github.com/uptrace/bun/extra/bundebug v1.1.7/go.mod h1:WoBnTrBG9CXITZUw+UfF+DYjWi71boo8FKZGuS5qDzA= 121 | github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 122 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 123 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 124 | github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= 125 | github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= 126 | github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= 127 | github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM= 128 | github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8= 129 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 130 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 131 | github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= 132 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 133 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 134 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 135 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 136 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 137 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 138 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 139 | golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 140 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 141 | golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= 142 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 143 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 144 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 145 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 146 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 147 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 148 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 149 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 150 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 151 | golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 152 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 153 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 154 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 155 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 156 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 157 | golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7 h1:dtndE8FcEta75/4kHF3AbpuWzV6f1LjnLrM4pe2SZrw= 158 | golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= 159 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 160 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 161 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 162 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 164 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 165 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 166 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 167 | golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 169 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 170 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 171 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 172 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 173 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 174 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 175 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 176 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 177 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 178 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 179 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 180 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 181 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 182 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 183 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 184 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 185 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 186 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 187 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 188 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 189 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 190 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 191 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 192 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 193 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 194 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 195 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 196 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 197 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 198 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 199 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 200 | gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA= 201 | gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI= 202 | gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= 203 | gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 204 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 205 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 206 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 207 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 208 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 209 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 210 | -------------------------------------------------------------------------------- /setVersionNumber.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cat << EOF > walrss/internal/core/version.go 4 | package core 5 | 6 | const Version = "$1" 7 | EOF -------------------------------------------------------------------------------- /walrss/internal/core/feedItems.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "github.com/codemicro/walrss/walrss/internal/db" 6 | "github.com/codemicro/walrss/walrss/internal/state" 7 | ) 8 | 9 | //func NewFeedItem(st *state.State, feedID, itemID string) (*db.FeedItem, error) { 10 | // fi := &db.FeedItem{ 11 | // FeedID: feedID, 12 | // ItemID: itemID, 13 | // } 14 | // return fi, NewFeedItems(st, []*db.FeedItem{fi}) 15 | //} 16 | 17 | func NewFeedItems(st *state.State, fis []*db.FeedItem) error { 18 | if len(fis) == 0 { 19 | return nil 20 | } 21 | _, err := st.Data.NewInsert().Model(&fis).Exec(context.Background()) 22 | return err 23 | } 24 | 25 | func GetFeedItemsForFeed(st *state.State, feedID string) (res []*db.FeedItem, err error) { 26 | err = st.Data.NewSelect(). 27 | Model(&res). 28 | Where("feed_id = ?", feedID). 29 | Scan(context.Background()) 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /walrss/internal/core/feeds.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "github.com/codemicro/walrss/walrss/internal/core/opml" 8 | "github.com/codemicro/walrss/walrss/internal/db" 9 | "github.com/codemicro/walrss/walrss/internal/state" 10 | "github.com/lithammer/shortuuid/v4" 11 | ) 12 | 13 | func NewFeed(st *state.State, userID, name, url string) (*db.Feed, error) { 14 | if err := validateFeedName(name); err != nil { 15 | return nil, err 16 | } 17 | 18 | if err := validateURL(url); err != nil { 19 | return nil, err 20 | } 21 | 22 | feed := &db.Feed{ 23 | ID: shortuuid.New(), 24 | URL: url, 25 | Name: name, 26 | UserID: userID, 27 | } 28 | 29 | if _, err := st.Data.NewInsert().Model(feed).Exec(context.Background()); err != nil { 30 | return nil, err 31 | } 32 | 33 | return feed, nil 34 | } 35 | 36 | func NewFeeds(st *state.State, userID string, fs []*db.Feed) error { 37 | if len(fs) == 0 { 38 | return nil 39 | } 40 | 41 | for i, f := range fs { 42 | f.ID = shortuuid.New() 43 | f.UserID = userID 44 | if err := validateFeedName(f.Name); err != nil { 45 | return NewUserErrorWithStatus(400, "validate feed %d: %w", i, err) 46 | } 47 | if err := validateURL(f.URL); err != nil { 48 | return NewUserErrorWithStatus(400, "validate feed %d: %w", i, err) 49 | } 50 | } 51 | 52 | _, err := st.Data.NewInsert().Model(&fs).Exec(context.Background()) 53 | return err 54 | } 55 | 56 | func GetFeedsForUser(st *state.State, userID string) (res []*db.Feed, err error) { 57 | err = st.Data.NewSelect(). 58 | Model(&res). 59 | Relation("User"). 60 | Where("Feed.user_id = ?", userID). 61 | Scan(context.Background()) 62 | return 63 | } 64 | 65 | func GetFeed(st *state.State, id string) (res *db.Feed, err error) { 66 | res = new(db.Feed) 67 | err = st.Data.NewSelect().Model(res).Where("id = ?", id).Scan(context.Background()) 68 | if errors.Is(err, sql.ErrNoRows) { 69 | return nil, ErrNotFound 70 | } 71 | return 72 | } 73 | 74 | func DeleteFeed(st *state.State, id string) error { 75 | _, err := st.Data.NewDelete().Model((*db.Feed)(nil)).Where("id = ?", id).Exec(context.Background()) 76 | return err 77 | } 78 | 79 | func UpdateFeed(st *state.State, feed *db.Feed) error { 80 | if err := validateFeedName(feed.Name); err != nil { 81 | return err 82 | } 83 | 84 | if err := validateURL(feed.URL); err != nil { 85 | return err 86 | } 87 | 88 | _, err := st.Data.NewUpdate().Model(feed).WherePK().Exec(context.Background()) 89 | if errors.Is(err, sql.ErrNoRows) { 90 | return ErrNotFound 91 | } 92 | return err 93 | } 94 | 95 | func ExportFeedsForUser(st *state.State, userID string) ([]byte, error) { 96 | var feeds []*db.Feed 97 | user, err := GetUserByID(st, userID) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | if err := st.Data.NewSelect(). 103 | Model(&feeds). 104 | Where("user_id = ?", userID). 105 | Scan(context.Background()); err != nil { 106 | return nil, err 107 | } 108 | 109 | return opml.FromFeeds(feeds, user.Email).ToBytes() 110 | } 111 | 112 | func ImportFeedsForUser(st *state.State, userID string, opmlXML []byte) error { 113 | o, err := opml.FromBytes(opmlXML) 114 | if err != nil { 115 | return AsUserError(400, err) 116 | } 117 | 118 | // This will be used to filter out feeds included in OPML that would cause 119 | // duplicates 120 | existingURLs := make(map[string]struct{}) 121 | { 122 | feeds, err := GetFeedsForUser(st, userID) 123 | if err != nil { 124 | return err 125 | } 126 | for _, feed := range feeds { 127 | if _, found := existingURLs[feed.URL]; !found { 128 | existingURLs[feed.URL] = struct{}{} 129 | } 130 | } 131 | } 132 | 133 | var ( 134 | fs = o.ToFeeds() 135 | n int 136 | ) 137 | for _, feed := range fs { 138 | if _, found := existingURLs[feed.URL]; !found { 139 | fs[n] = feed 140 | n += 1 141 | } 142 | } 143 | fs = fs[:n] 144 | 145 | return NewFeeds(st, userID, fs) 146 | } 147 | -------------------------------------------------------------------------------- /walrss/internal/core/opml/opml.go: -------------------------------------------------------------------------------- 1 | package opml 2 | 3 | import ( 4 | "encoding/xml" 5 | "github.com/codemicro/walrss/walrss/internal/db" 6 | "github.com/lithammer/shortuuid/v4" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type OPML struct { 12 | XMLName xml.Name `xml:"opml"` 13 | Version string `xml:"version,attr"` 14 | Head struct { 15 | Title string `xml:"title"` 16 | DateCreated time.Time `xml:"dateCreated,omitempty"` 17 | OwnerEmail string `xml:"ownerEmail,omitempty"` 18 | } `xml:"head"` 19 | Body struct { 20 | Outlines []*Outline `xml:"outline"` 21 | } `xml:"body"` 22 | } 23 | 24 | func FromBytes(x []byte) (*OPML, error) { 25 | o := new(OPML) 26 | if err := xml.Unmarshal(x, o); err != nil { 27 | return nil, err 28 | } 29 | return o, nil 30 | } 31 | 32 | func FromFeeds(feeds []*db.Feed, userEmailAddress string) *OPML { 33 | o := new(OPML) 34 | o.Version = "2.0" 35 | o.Head.Title = "Walrss feed export" 36 | o.Head.OwnerEmail = userEmailAddress 37 | o.Head.DateCreated = time.Now().UTC() 38 | 39 | for _, feed := range feeds { 40 | o.Body.Outlines = append(o.Body.Outlines, &Outline{ 41 | Text: feed.Name, 42 | Title: feed.Name, 43 | Type: "rss", 44 | XMLURL: feed.URL, 45 | }) 46 | } 47 | 48 | return o 49 | } 50 | 51 | func (o *OPML) ToBytes() ([]byte, error) { 52 | return xml.Marshal(o) 53 | } 54 | 55 | func (o *OPML) ToFeeds() []*db.Feed { 56 | var out []*db.Feed 57 | for _, item := range o.Body.Outlines { 58 | out = append(out, item.ToFeeds()...) 59 | } 60 | return out 61 | } 62 | 63 | type Outline struct { 64 | Outlines []*Outline `xml:"outline"` 65 | Text string `xml:"text,attr"` 66 | Title string `xml:"title,attr,omitempty"` 67 | Type string `xml:"type,attr,omitempty"` 68 | XMLURL string `xml:"xmlUrl,attr,omitempty"` 69 | } 70 | 71 | func (o *Outline) ToFeeds() []*db.Feed { 72 | var out []*db.Feed 73 | 74 | if strings.EqualFold(o.Type, "rss") { 75 | name := o.Text 76 | if o.Title != "" { 77 | name = o.Title 78 | } 79 | 80 | out = append(out, &db.Feed{ 81 | ID: shortuuid.New(), 82 | URL: o.XMLURL, 83 | Name: name, 84 | }) 85 | } 86 | 87 | for _, item := range o.Outlines { 88 | out = append(out, item.ToFeeds()...) 89 | } 90 | 91 | return out 92 | } 93 | -------------------------------------------------------------------------------- /walrss/internal/core/sessions.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | goalone "github.com/bwmarrin/go-alone" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | var ( 12 | sessionSigner *goalone.Sword 13 | sessionSalt = []byte("session") 14 | ) 15 | 16 | func init() { 17 | sessionSecret := make([]byte, 50) 18 | if _, err := rand.Read(sessionSecret); err != nil { 19 | panic(err) 20 | } 21 | sessionSigner = goalone.New(sessionSecret, goalone.Timestamp) 22 | } 23 | 24 | func GenerateSessionToken(userID string) string { 25 | combined := append([]byte(userID), sessionSalt...) 26 | return hex.EncodeToString(sessionSigner.Sign(combined)) 27 | } 28 | 29 | func ValidateSessionToken(input string) (string, time.Time, error) { 30 | signed, err := hex.DecodeString(input) 31 | if err != nil { 32 | return "", time.Time{}, err 33 | } 34 | 35 | if _, err := sessionSigner.Unsign(signed); err != nil { 36 | return "", time.Time{}, AsUserError(400, err) 37 | } 38 | 39 | parsed := sessionSigner.Parse(signed) 40 | return strings.TrimSuffix(string(parsed.Payload), string(sessionSalt)), parsed.Timestamp, nil 41 | } 42 | -------------------------------------------------------------------------------- /walrss/internal/core/userError.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "fmt" 4 | 5 | var ErrNotFound = NewUserErrorWithStatus(404, "item not found") 6 | 7 | type UserError struct { 8 | Original error 9 | Status int 10 | } 11 | 12 | func (ue *UserError) Error() string { 13 | return ue.Original.Error() 14 | } 15 | 16 | func (ue *UserError) Unwrap() error { 17 | return ue.Original 18 | } 19 | 20 | func AsUserError(status int, err error) error { 21 | return &UserError{ 22 | Original: err, 23 | Status: status, 24 | } 25 | } 26 | 27 | func NewUserError(format string, args ...any) error { 28 | return NewUserErrorWithStatus(400, format, args...) 29 | } 30 | 31 | func NewUserErrorWithStatus(status int, format string, args ...any) error { 32 | return &UserError{ 33 | Original: fmt.Errorf(format, args...), 34 | Status: status, 35 | } 36 | } 37 | 38 | func IsUserError(err error) bool { 39 | if err == nil { 40 | return false 41 | } 42 | _, ok := err.(*UserError) 43 | return ok 44 | } 45 | 46 | func GetUserErrorStatus(err error) int { 47 | ue, ok := err.(*UserError) 48 | if !ok { 49 | return 0 50 | } 51 | return ue.Status 52 | } 53 | -------------------------------------------------------------------------------- /walrss/internal/core/users.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "github.com/codemicro/walrss/walrss/internal/db" 8 | "github.com/codemicro/walrss/walrss/internal/state" 9 | "github.com/lithammer/shortuuid/v4" 10 | "github.com/mattn/go-sqlite3" 11 | "golang.org/x/crypto/bcrypt" 12 | ) 13 | 14 | func RegisterUser(st *state.State, email, password string) (*db.User, error) { 15 | if err := validateEmailAddress(email); err != nil { 16 | return nil, err 17 | } 18 | 19 | if err := validatePassword(password); err != nil { 20 | return nil, err 21 | } 22 | 23 | u := &db.User{ 24 | ID: shortuuid.New(), 25 | Email: email, 26 | Salt: generateRandomData(30), 27 | } 28 | 29 | hash, err := bcrypt.GenerateFromPassword(combineStringAndSalt(password, u.Salt), bcrypt.DefaultCost) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | u.Password = hash 35 | 36 | if err := coreRegisterUser(st, u); err != nil { 37 | return nil, err 38 | } 39 | 40 | return u, nil 41 | } 42 | 43 | func RegisterUserOIDC(st *state.State, email string) (*db.User, error) { 44 | u := &db.User{ 45 | ID: shortuuid.New(), 46 | Email: email, 47 | } 48 | 49 | if err := coreRegisterUser(st, u); err != nil { 50 | return nil, err 51 | } 52 | 53 | return u, nil 54 | } 55 | 56 | func coreRegisterUser(st *state.State, u *db.User) error { 57 | if _, err := st.Data.NewInsert().Model(u).Exec(context.Background()); err != nil { 58 | if e, ok := err.(sqlite3.Error); ok { 59 | if e.Code == sqlite3.ErrConstraint { 60 | return NewUserError("email address in use") 61 | } 62 | } 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | func AreUserCredentialsCorrect(st *state.State, email, password string) (bool, error) { 69 | user, err := GetUserByEmail(st, email) 70 | if err != nil { 71 | return false, err 72 | } 73 | 74 | if len(user.Password) == 0 { 75 | return false, nil 76 | } 77 | 78 | if err := bcrypt.CompareHashAndPassword(user.Password, combineStringAndSalt(password, user.Salt)); err != nil { 79 | if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { 80 | return false, nil 81 | } 82 | return false, err 83 | } 84 | 85 | return true, nil 86 | } 87 | 88 | func GetUserByID(st *state.State, userID string) (res *db.User, err error) { 89 | res = new(db.User) 90 | err = st.Data.NewSelect().Model(res).Where("id = ?", userID).Scan(context.Background()) 91 | if errors.Is(err, sql.ErrNoRows) { 92 | return nil, ErrNotFound 93 | } 94 | return 95 | } 96 | 97 | func GetUserByEmail(st *state.State, email string) (res *db.User, err error) { 98 | res = new(db.User) 99 | err = st.Data.NewSelect().Model(res).Where("email = ?", email).Scan(context.Background()) 100 | if errors.Is(err, sql.ErrNoRows) { 101 | return nil, ErrNotFound 102 | } 103 | return 104 | } 105 | 106 | func UpdateUser(st *state.State, user *db.User) error { 107 | _, err := st.Data.NewUpdate().Model(user).WherePK().Exec(context.Background()) 108 | if errors.Is(err, sql.ErrNoRows) { 109 | return ErrNotFound 110 | } 111 | return err 112 | } 113 | 114 | func GetUsersBySchedule(st *state.State, day db.SendDay, hour int) (res []*db.User, err error) { 115 | err = st.Data.NewSelect(). 116 | Model(&res). 117 | Where( 118 | "active = ? and (schedule_day = ? or schedule_day = ?) and schedule_hour = ?", 119 | true, 120 | day, 121 | db.SendDaily, 122 | hour, 123 | ). 124 | Scan(context.Background()) 125 | return 126 | } 127 | -------------------------------------------------------------------------------- /walrss/internal/core/util.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/rand" 5 | ) 6 | 7 | func generateRandomData(n int) []byte { 8 | bytes := make([]byte, n) 9 | _, _ = rand.Read(bytes) 10 | return bytes 11 | } 12 | 13 | func combineStringAndSalt(password string, salt []byte) []byte { 14 | return append([]byte(password), salt...) 15 | } 16 | -------------------------------------------------------------------------------- /walrss/internal/core/validation.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var emailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 10 | 11 | func validateEmailAddress(email string) error { 12 | if !emailRegexp.MatchString(email) { 13 | return NewUserError("invalid email address") 14 | } 15 | return nil 16 | } 17 | 18 | func validatePassword(password string) error { 19 | if len(password) <= 3 { 20 | return NewUserError("password must be at least three characters long") 21 | } 22 | return nil 23 | } 24 | 25 | func validateFeedName(name string) error { 26 | if strings.TrimSpace(name) == "" { 27 | return NewUserError("feed name cannot be blank") 28 | } 29 | return nil 30 | } 31 | 32 | func validateURL(inputURL string) error { 33 | u, err := url.ParseRequestURI(inputURL) 34 | if err != nil { 35 | return NewUserError("invalid URL") 36 | } 37 | if s := strings.ToLower(u.Scheme); !(s == "http" || s == "https") { 38 | return NewUserError("invalid URL request scheme - must be HTTP or HTTPS") 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /walrss/internal/core/version.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | const Version = "" 4 | -------------------------------------------------------------------------------- /walrss/internal/db/00000000000001_initialise.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "users" 2 | ( 3 | "id" VARCHAR NOT NULL, 4 | "email" VARCHAR NOT NULL, 5 | "password" BLOB, 6 | "salt" BLOB, 7 | "active" BOOLEAN NOT NULL, 8 | "schedule_day" INTEGER, 9 | "schedule_hour" INTEGER, 10 | PRIMARY KEY ("id"), 11 | UNIQUE ("email") 12 | ) 13 | 14 | --bun:split 15 | 16 | CREATE TABLE "feeds" 17 | ( 18 | "id" VARCHAR NOT NULL, 19 | "url" VARCHAR NOT NULL, 20 | "name" VARCHAR NOT NULL, 21 | "user_id" VARCHAR NOT NULL, 22 | PRIMARY KEY ("id") 23 | ) -------------------------------------------------------------------------------- /walrss/internal/db/20230119135807_etags.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE feeds ADD COLUMN last_etag VARCHAR 2 | --bun:split 3 | ALTER TABLE feeds ADD COLUMN cached_content VARCHAR -------------------------------------------------------------------------------- /walrss/internal/db/20230119165107_lastmodified.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE feeds ADD COLUMN last_modified VARCHAR -------------------------------------------------------------------------------- /walrss/internal/db/20250209222240_lastfetched.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE feeds ADD COLUMN last_fetched TEXT -------------------------------------------------------------------------------- /walrss/internal/db/20250209224630_feeditems.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "feed_items" 2 | ( 3 | feed_id VARCHAR NOT NULL, 4 | item_id VARCHAR NOT NULL, 5 | PRIMARY KEY ("feed_id", "item_id"), 6 | FOREIGN KEY ("feed_id") REFERENCES "feeds"("id") ON DELETE CASCADE 7 | ); 8 | -------------------------------------------------------------------------------- /walrss/internal/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | _ "github.com/mattn/go-sqlite3" 7 | "github.com/rs/zerolog/log" 8 | "github.com/uptrace/bun" 9 | "github.com/uptrace/bun/dialect/sqlitedialect" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | func New(filename string) (*bun.DB, error) { 15 | dsn := filename 16 | log.Info().Msg("connecting to database") 17 | db, err := sql.Open("sqlite3", dsn) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | db.SetMaxOpenConns(1) // https://github.com/mattn/go-sqlite3/issues/274#issuecomment-191597862 23 | 24 | if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 25 | return nil, fmt.Errorf("setting PRAGMA foreign_keys = ON: %w", err) 26 | } 27 | 28 | return bun.NewDB(db, sqlitedialect.New()), nil 29 | } 30 | 31 | type User struct { 32 | bun.BaseModel `bun:"table:users"` 33 | 34 | ID string `bun:"id,pk"` 35 | Email string `bun:"email,notnull,unique"` 36 | Password []byte `bun:"password"` 37 | Salt []byte `bun:"salt"` 38 | 39 | Active bool `bun:"active,notnull"` 40 | ScheduleDay SendDay `bun:"schedule_day"` 41 | ScheduleHour int `bun:"schedule_hour"` 42 | } 43 | 44 | type Feed struct { 45 | bun.BaseModel `bun:"table:feeds"` 46 | 47 | ID string `bun:"id,pk"` 48 | URL string `bun:"url,notnull"` 49 | Name string `bun:"name,notnull"` 50 | UserID string `bun:"user_id,notnull"` 51 | 52 | LastFetched time.Time `bun:"last_fetched,nullzero"` 53 | LastEtag string `bun:"last_etag,nullzero"` 54 | LastModified string `bun:"last_modified,nullzero"` 55 | CachedContent string `bun:"cached_content,nullzero"` 56 | 57 | User *User `bun:",rel:belongs-to,join:user_id=id"` 58 | } 59 | 60 | func (f *Feed) SetCacheWithEtag(etag, content string) { 61 | f.LastModified = "" 62 | f.LastEtag = etag 63 | f.CachedContent = content 64 | } 65 | 66 | func (f *Feed) SetCacheWithLastModified(lastModified, content string) { 67 | f.LastEtag = "" 68 | f.LastModified = lastModified 69 | f.CachedContent = content 70 | } 71 | 72 | func (f *Feed) ClearCache() { 73 | f.LastEtag = "" 74 | f.LastModified = "" 75 | f.CachedContent = "" 76 | } 77 | 78 | type FeedSlice []*Feed 79 | 80 | func (f FeedSlice) Len() int { 81 | return len(f) 82 | } 83 | 84 | func (f FeedSlice) Less(i, j int) bool { 85 | return strings.ToLower(f[i].Name) < strings.ToLower(f[j].Name) 86 | } 87 | 88 | func (f FeedSlice) Swap(i, j int) { 89 | f[i], f[j] = f[j], f[i] 90 | } 91 | 92 | type FeedItem struct { 93 | bun.BaseModel `bun:"table:feed_items"` 94 | 95 | FeedID string `bun:"feed_id,notnull"` 96 | ItemID string `bun:"item_id,notnull"` 97 | 98 | // Feed *Feed `bun:",rel:belongs-to,join:feed_id=id"` // don't think this is needed but here in case?? 99 | } 100 | -------------------------------------------------------------------------------- /walrss/internal/db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "github.com/rs/zerolog/log" 7 | "github.com/uptrace/bun" 8 | "github.com/uptrace/bun/migrate" 9 | "time" 10 | ) 11 | 12 | var migs = migrate.NewMigrations() 13 | 14 | //go:embed *.sql 15 | var sqlMigrations embed.FS 16 | 17 | func init() { 18 | if err := migs.Discover(sqlMigrations); err != nil { 19 | panic(err) 20 | } 21 | } 22 | 23 | func DoMigrations(db *bun.DB) error { 24 | log.Info().Msg("running migrations") 25 | 26 | mig := migrate.NewMigrator(db, migs) 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 29 | defer cancel() 30 | 31 | if err := mig.Init(ctx); err != nil { 32 | return err 33 | } 34 | 35 | group, err := mig.Migrate(ctx) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if group.IsZero() { 41 | log.Info().Msg("database up to date") 42 | } else { 43 | log.Info().Msg("migrations applied") 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /walrss/internal/db/sendDay.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type SendDay uint32 10 | 11 | const ( 12 | SendDayNever SendDay = iota 13 | SendDaily 14 | SendOnMonday 15 | SendOnTuesday 16 | SendOnWednesday 17 | SendOnThursday 18 | SendOnFriday 19 | SendOnSaturday 20 | SendOnSunday 21 | LastSendDay 22 | ) 23 | 24 | func (s SendDay) String() string { 25 | var x string 26 | 27 | switch s { 28 | case SendDayNever: 29 | x = "never" 30 | case SendDaily: 31 | x = "daily" 32 | case SendOnMonday: 33 | x = "Monday" 34 | case SendOnTuesday: 35 | x = "Tuesday" 36 | case SendOnWednesday: 37 | x = "Wednesday" 38 | case SendOnThursday: 39 | x = "Thursday" 40 | case SendOnFriday: 41 | x = "Friday" 42 | case SendOnSaturday: 43 | x = "Saturday" 44 | case SendOnSunday: 45 | x = "Sunday" 46 | } 47 | 48 | return x 49 | } 50 | 51 | func (s SendDay) MarshalText() ([]byte, error) { 52 | return []byte(s.String()), nil 53 | } 54 | 55 | func (s *SendDay) UnmarshalText(x []byte) error { 56 | 57 | switch strings.ToLower(string(x)) { 58 | case "never": 59 | *s = SendDayNever 60 | case "daily": 61 | *s = SendDaily 62 | case "monday": 63 | *s = SendOnMonday 64 | case "tuesday": 65 | *s = SendOnTuesday 66 | case "wednesday": 67 | *s = SendOnWednesday 68 | case "thursday": 69 | *s = SendOnThursday 70 | case "friday": 71 | *s = SendOnFriday 72 | case "saturday": 73 | *s = SendOnSaturday 74 | case "sunday": 75 | *s = SendOnSunday 76 | default: 77 | return errors.New("unrecognised day") 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func SendDayFromWeekday(w time.Weekday) SendDay { 84 | s := new(SendDay) 85 | if err := s.UnmarshalText([]byte(w.String())); err != nil { 86 | panic(err) 87 | } 88 | return *s 89 | } 90 | -------------------------------------------------------------------------------- /walrss/internal/http/auth.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/codemicro/walrss/walrss/internal/core" 7 | "github.com/codemicro/walrss/walrss/internal/http/views" 8 | "github.com/codemicro/walrss/walrss/internal/urls" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/stevelacy/daz" 11 | "math/rand" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | func (s *Server) authRegister(ctx *fiber.Ctx) error { 17 | 18 | if s.state.Config.Platform.DisableRegistration { 19 | ctx.Status(fiber.StatusForbidden) 20 | return views.SendPage(ctx, &views.PolyPage{ 21 | TitleString: "Site registration disabled", 22 | BodyContent: daz.H("div", 23 | daz.Attr{"class": "container alert alert-danger"}, 24 | "We're sorry - ", 25 | daz.H("b", "this instance of Walrss has registrations disabled"), 26 | ". Please contact the operator of this Walrss instance with any queries.", 27 | )(), 28 | }) 29 | } 30 | 31 | page := new(views.RegisterPage) 32 | 33 | if getCurrentUserID(ctx) != "" { 34 | goto success 35 | } 36 | 37 | if ctx.Method() == fiber.MethodPost { 38 | password := ctx.FormValue("password") 39 | passwordConfirmation := ctx.FormValue("passwordConfirmation") 40 | if password != passwordConfirmation { 41 | page.Problem = "Passwords do not match" 42 | goto exit 43 | } 44 | 45 | user, err := core.RegisterUser( 46 | s.state, 47 | ctx.FormValue("email"), 48 | password, 49 | ) 50 | if err != nil { 51 | if core.IsUserError(err) { 52 | ctx.Status(core.GetUserErrorStatus(err)) 53 | page.Problem = "Could not register account: " + err.Error() 54 | goto exit 55 | } 56 | return err 57 | } 58 | 59 | token := core.GenerateSessionToken(user.ID) 60 | 61 | ctx.Cookie(&fiber.Cookie{ 62 | Name: sessionCookieKey, 63 | Value: token, 64 | Expires: time.Now().UTC().Add(sessionDuration), 65 | Secure: !s.state.Config.Debug, 66 | HTTPOnly: true, 67 | }) 68 | 69 | goto success 70 | } 71 | 72 | exit: 73 | return views.SendPage(ctx, page) 74 | success: 75 | return ctx.Redirect(urls.Index) 76 | } 77 | 78 | func (s *Server) authSignIn(ctx *fiber.Ctx) error { 79 | page := &views.SignInPage{ 80 | Problem: ctx.Query("problem"), 81 | OIDCEnabled: s.state.Config.OIDC.Enable, 82 | } 83 | 84 | if getCurrentUserID(ctx) != "" { 85 | goto success 86 | } 87 | 88 | if ctx.Method() == fiber.MethodPost { 89 | email := ctx.FormValue("email") 90 | 91 | ok, err := core.AreUserCredentialsCorrect( 92 | s.state, 93 | email, 94 | ctx.FormValue("password"), 95 | ) 96 | if err != nil { 97 | if errors.Is(err, core.ErrNotFound) { 98 | goto incorrectUsernameOrPassword 99 | } 100 | return err 101 | } 102 | 103 | if !ok { 104 | goto incorrectUsernameOrPassword 105 | } 106 | 107 | user, err := core.GetUserByEmail(s.state, email) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | token := core.GenerateSessionToken(user.ID) 113 | 114 | ctx.Cookie(&fiber.Cookie{ 115 | Name: sessionCookieKey, 116 | Value: token, 117 | Expires: time.Now().UTC().Add(sessionDuration), 118 | Secure: s.state.Config.EnableSecureCookies(), 119 | HTTPOnly: true, 120 | }) 121 | 122 | goto success 123 | } 124 | 125 | return views.SendPage(ctx, page) 126 | success: 127 | return ctx.Redirect( 128 | ctx.Query("next", urls.Index), 129 | ) 130 | incorrectUsernameOrPassword: 131 | ctx.Status(fiber.StatusUnauthorized) 132 | return views.SendPage(ctx, &views.SignInPage{Problem: "Incorrect username or password"}) 133 | } 134 | 135 | var ( 136 | knownStates = make(map[string]time.Time) 137 | stateLock sync.Mutex 138 | ) 139 | 140 | func init() { 141 | rand.Seed(time.Now().Unix()) 142 | 143 | go func() { 144 | time.Sleep(time.Minute * 5) 145 | stateLock.Lock() 146 | 147 | var toDelete []string 148 | 149 | for k, v := range knownStates { 150 | if !v.After(time.Now().UTC()) { 151 | toDelete = append(toDelete, k) 152 | } 153 | } 154 | 155 | for _, k := range toDelete { 156 | delete(knownStates, k) 157 | } 158 | 159 | stateLock.Unlock() 160 | }() 161 | } 162 | 163 | func (s *Server) authOIDCOutbound(ctx *fiber.Ctx) error { 164 | if !s.state.Config.OIDC.Enable { 165 | return core.NewUserErrorWithStatus(fiber.StatusForbidden, "OIDC is disabled") 166 | } 167 | 168 | b := make([]byte, 30) 169 | for i := 0; i < len(b); i++ { 170 | b[i] = byte(65 + rand.Intn(25)) 171 | } 172 | knownStates[string(b)] = time.Now().UTC().Add(time.Minute * 2) 173 | 174 | return ctx.Redirect(s.oauth2Config.AuthCodeURL(string(b))) 175 | } 176 | 177 | func (s *Server) authOIDCCallback(ctx *fiber.Ctx) error { 178 | if !s.state.Config.OIDC.Enable { 179 | return core.NewUserErrorWithStatus(fiber.StatusForbidden, "OIDC is disabled") 180 | } 181 | 182 | providedState := ctx.Query("state") 183 | stateLock.Lock() 184 | if exp, ok := knownStates[providedState]; ok && exp.After(time.Now().UTC()) { 185 | delete(knownStates, providedState) 186 | stateLock.Unlock() 187 | } else { 188 | stateLock.Unlock() 189 | return core.NewUserError("Invalid state") 190 | } 191 | 192 | oauth2Token, err := s.oauth2Config.Exchange(context.Background(), ctx.Query("code")) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | rawIDToken, ok := oauth2Token.Extra("id_token").(string) 198 | if !ok { 199 | return errors.New("missing ID token") 200 | } 201 | 202 | idToken, err := s.oidcVerifier.Verify(context.Background(), rawIDToken) 203 | if err != nil { 204 | return err 205 | } 206 | 207 | var claims struct { 208 | Email string `json:"email"` 209 | } 210 | if err := idToken.Claims(&claims); err != nil { 211 | return err 212 | } 213 | 214 | user, err := core.GetUserByEmail(s.state, claims.Email) 215 | if err != nil { 216 | if errors.Is(err, core.ErrNotFound) { 217 | if s.state.Config.Platform.DisableRegistration { 218 | return core.NewUserError("Cannot register user on-demand as registrations are disabled.") 219 | } 220 | user, err = core.RegisterUserOIDC(s.state, claims.Email) 221 | if err != nil { 222 | return err 223 | } 224 | } else { 225 | return err 226 | } 227 | } 228 | 229 | token := core.GenerateSessionToken(user.ID) 230 | 231 | ctx.Cookie(&fiber.Cookie{ 232 | Name: sessionCookieKey, 233 | Value: token, 234 | Expires: time.Now().UTC().Add(sessionDuration), 235 | Secure: s.state.Config.EnableSecureCookies(), 236 | HTTPOnly: true, 237 | }) 238 | 239 | return ctx.Redirect(urls.Index) 240 | } 241 | -------------------------------------------------------------------------------- /walrss/internal/http/edit.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/core" 5 | "github.com/codemicro/walrss/walrss/internal/db" 6 | "github.com/codemicro/walrss/walrss/internal/http/views" 7 | "github.com/codemicro/walrss/walrss/internal/urls" 8 | "github.com/gofiber/fiber/v2" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | func (s *Server) editEnabledState(ctx *fiber.Ctx) error { 14 | currentUserID := getCurrentUserID(ctx) 15 | if currentUserID == "" { 16 | return requestFragmentSignIn(ctx, urls.Index) 17 | } 18 | 19 | user, err := core.GetUserByID(s.state, currentUserID) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if strings.ToLower(ctx.FormValue("enable", "off")) == "on" { 25 | user.Active = true 26 | } else { 27 | user.Active = false 28 | } 29 | 30 | if err := core.UpdateUser(s.state, user); err != nil { 31 | return err 32 | } 33 | 34 | fragmentEmitSuccess(ctx) 35 | return ctx.SendString((&views.MainPage{ 36 | EnableDigests: user.Active, 37 | SelectedDay: user.ScheduleDay, 38 | SelectedTime: user.ScheduleHour, 39 | }).RenderScheduleCard()) 40 | } 41 | 42 | func (s *Server) editTimings(ctx *fiber.Ctx) error { 43 | currentUserID := getCurrentUserID(ctx) 44 | if currentUserID == "" { 45 | return requestFragmentSignIn(ctx, urls.Index) 46 | } 47 | 48 | user, err := core.GetUserByID(s.state, currentUserID) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if n, err := strconv.ParseInt(ctx.FormValue("day"), 10, 32); err != nil { 54 | return core.AsUserError(fiber.StatusBadRequest, err) 55 | } else { 56 | x := db.SendDay(n) 57 | if x > db.SendOnSunday || x < 0 { 58 | return core.NewUserError("invalid day: out of range 0<=x<=%d", int(db.SendOnSunday)) 59 | } 60 | user.ScheduleDay = x 61 | } 62 | 63 | if n, err := strconv.ParseInt(ctx.FormValue("time"), 10, 8); err != nil { 64 | return core.AsUserError(fiber.StatusBadRequest, err) 65 | } else { 66 | x := int(n) 67 | if x > 23 || x < 0 { 68 | return core.NewUserError("invalid time: out of range 0<=x<=23") 69 | } 70 | user.ScheduleHour = x 71 | } 72 | 73 | if err := core.UpdateUser(s.state, user); err != nil { 74 | return err 75 | } 76 | 77 | fragmentEmitSuccess(ctx) 78 | return ctx.SendString((&views.MainPage{ 79 | EnableDigests: user.Active, 80 | SelectedDay: user.ScheduleDay, 81 | SelectedTime: user.ScheduleHour, 82 | }).RenderScheduleCard()) 83 | } 84 | 85 | func (s *Server) editFeedItem(ctx *fiber.Ctx) error { 86 | currentUserID := getCurrentUserID(ctx) 87 | if currentUserID == "" { 88 | return requestFragmentSignIn(ctx, urls.Index) 89 | } 90 | 91 | feedID := ctx.Params("id") 92 | 93 | feed, err := core.GetFeed(s.state, feedID) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | switch ctx.Method() { 99 | case fiber.MethodGet: 100 | return ctx.SendString(views.RenderFeedEditRow(feed.ID, feed.Name, feed.URL)) 101 | case fiber.MethodDelete: 102 | if err := core.DeleteFeed(s.state, feed.ID); err != nil { 103 | return err 104 | } 105 | return nil 106 | case fiber.MethodPut: 107 | feed.Name = ctx.FormValue("name") 108 | feed.URL = ctx.FormValue("url") 109 | 110 | if urlChanged := feed.URL != ctx.FormValue("old-url"); urlChanged { 111 | feed.ClearCache() 112 | } 113 | 114 | if err := core.UpdateFeed(s.state, feed); err != nil { 115 | return err 116 | } 117 | return ctx.SendString(views.RenderFeedRow(feed.ID, feed.Name, feed.URL)) 118 | } 119 | 120 | panic("unreachable") 121 | } 122 | 123 | func (s *Server) cancelEditFeedItem(ctx *fiber.Ctx) error { 124 | currentUserID := getCurrentUserID(ctx) 125 | if currentUserID == "" { 126 | return requestFragmentSignIn(ctx, urls.Index) 127 | } 128 | 129 | feedID := ctx.Params("id") 130 | 131 | feed, err := core.GetFeed(s.state, feedID) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | return ctx.SendString(views.RenderFeedRow(feed.ID, feed.Name, feed.URL)) 137 | } 138 | -------------------------------------------------------------------------------- /walrss/internal/http/exportImport.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "github.com/codemicro/walrss/walrss/internal/core" 6 | "github.com/codemicro/walrss/walrss/internal/urls" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/valyala/fasthttp" 9 | "io" 10 | ) 11 | 12 | func (s *Server) exportAsOPML(ctx *fiber.Ctx) error { 13 | currentUserID := getCurrentUserID(ctx) 14 | if currentUserID == "" { 15 | return requestStandardSignIn(ctx) 16 | } 17 | 18 | exported, err := core.ExportFeedsForUser(s.state, currentUserID) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | ctx.Set(fiber.HeaderContentType, "application/xml") 24 | return ctx.Send(exported) 25 | } 26 | 27 | func (s *Server) importFromOPML(ctx *fiber.Ctx) error { 28 | currentUserID := getCurrentUserID(ctx) 29 | if currentUserID == "" { 30 | return requestFragmentSignIn(ctx, urls.Index) 31 | } 32 | 33 | file, err := ctx.FormFile("file") 34 | if err != nil { 35 | if errors.Is(err, fasthttp.ErrMissingFile) { 36 | return core.NewUserError("missing file") 37 | } 38 | return err 39 | } 40 | 41 | fileHandle, err := file.Open() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | fileContents, err := io.ReadAll(fileHandle) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if err := core.ImportFeedsForUser(s.state, currentUserID, fileContents); err != nil { 52 | return err 53 | } 54 | 55 | ctx.Set("HX-Refresh", "true") 56 | ctx.Status(fiber.StatusNoContent) 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /walrss/internal/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/codemicro/walrss/walrss/internal/core" 7 | "github.com/codemicro/walrss/walrss/internal/http/views" 8 | "github.com/codemicro/walrss/walrss/internal/state" 9 | "github.com/codemicro/walrss/walrss/internal/static" 10 | "github.com/codemicro/walrss/walrss/internal/urls" 11 | "github.com/coreos/go-oidc" 12 | "github.com/gofiber/fiber/v2" 13 | "github.com/rs/zerolog/log" 14 | "github.com/stevelacy/daz" 15 | "golang.org/x/oauth2" 16 | "net/url" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | const ( 22 | sessionCookieKey = "walrss-session" 23 | sessionDuration = (time.Hour * 24) * 7 // 7 days 24 | userIDLocalKey = "userID" 25 | ) 26 | 27 | type Server struct { 28 | state *state.State 29 | app *fiber.App 30 | 31 | oidcProvider *oidc.Provider 32 | oidcVerifier *oidc.IDTokenVerifier 33 | oauth2Config *oauth2.Config 34 | } 35 | 36 | func New(st *state.State) (*Server, error) { 37 | app := fiber.New(fiber.Config{ 38 | DisableStartupMessage: !st.Config.Debug, 39 | AppName: "Walrss", 40 | ErrorHandler: func(ctx *fiber.Ctx, err error) error { 41 | code := fiber.StatusInternalServerError 42 | msg := "Internal Server Error" 43 | 44 | var ( 45 | fiberErr *fiber.Error 46 | userErr *core.UserError 47 | ) 48 | 49 | if errors.As(err, &fiberErr) { 50 | code = fiberErr.Code 51 | msg = fiberErr.Error() 52 | } else if errors.As(err, &userErr) { 53 | code = userErr.Status 54 | msg = userErr.Error() 55 | } else { 56 | log.Error().Err(err).Str("location", "http").Str("url", ctx.OriginalURL()).Send() 57 | } 58 | 59 | ctx.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) 60 | return ctx.Status(code).SendString(msg) 61 | }, 62 | }) 63 | 64 | s := &Server{ 65 | state: st, 66 | app: app, 67 | } 68 | 69 | if st.Config.OIDC.Enable { 70 | provider, err := oidc.NewProvider(context.Background(), st.Config.OIDC.Issuer) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | s.oidcProvider = provider 76 | s.oidcVerifier = provider.Verifier(&oidc.Config{ClientID: st.Config.OIDC.ClientID}) 77 | s.oauth2Config = &oauth2.Config{ 78 | ClientID: st.Config.OIDC.ClientID, 79 | ClientSecret: st.Config.OIDC.ClientSecret, 80 | Endpoint: provider.Endpoint(), 81 | RedirectURL: strings.TrimSuffix(st.Config.Server.ExternalURL, "/") + urls.AuthOIDCCallback, 82 | Scopes: []string{"email", "profile", "openid"}, 83 | } 84 | } 85 | 86 | s.registerHandlers() 87 | 88 | return s, nil 89 | } 90 | 91 | func (s *Server) registerHandlers() { 92 | s.app.Use(func(ctx *fiber.Ctx) error { 93 | if token := ctx.Cookies(sessionCookieKey); token != "" { 94 | userID, createdAt, err := core.ValidateSessionToken(token) 95 | if err == nil && time.Now().Sub(createdAt) < sessionDuration { 96 | ctx.Locals(userIDLocalKey, userID) 97 | } 98 | } 99 | 100 | return ctx.Next() 101 | }) 102 | 103 | s.app.Get(urls.Index, s.mainPage) 104 | 105 | s.app.Get(urls.AuthRegister, s.authRegister) 106 | s.app.Post(urls.AuthRegister, s.authRegister) 107 | 108 | s.app.Get(urls.AuthSignIn, s.authSignIn) 109 | s.app.Post(urls.AuthSignIn, s.authSignIn) 110 | 111 | s.app.Get(urls.AuthOIDCOutbound, s.authOIDCOutbound) 112 | s.app.Get(urls.AuthOIDCCallback, s.authOIDCCallback) 113 | 114 | s.app.Put(urls.EditEnabledState, s.editEnabledState) 115 | s.app.Put(urls.EditTimings, s.editTimings) 116 | 117 | s.app.Get(urls.EditFeedItem, s.editFeedItem) 118 | s.app.Put(urls.EditFeedItem, s.editFeedItem) 119 | s.app.Delete(urls.EditFeedItem, s.editFeedItem) 120 | s.app.Get(urls.CancelEditFeedItem, s.cancelEditFeedItem) 121 | 122 | s.app.Get(urls.NewFeedItem, s.newFeedItem) 123 | s.app.Post(urls.NewFeedItem, s.newFeedItem) 124 | 125 | s.app.Post(urls.SendTestEmail, s.sendTestEmail) 126 | s.app.Get(urls.TestEmailStatus, s.testEmailStatus) 127 | 128 | s.app.Get(urls.ExportAsOPML, s.exportAsOPML) 129 | s.app.Post(urls.ImportFromOPML, s.importFromOPML) 130 | 131 | s.app.Use(urls.Statics, static.NewHandler()) 132 | } 133 | 134 | func (s *Server) Run() error { 135 | return s.app.Listen(s.state.Config.GetHTTPAddress()) 136 | } 137 | 138 | func userErrorToResponse(ctx *fiber.Ctx, ue core.UserError) error { 139 | ctx.Status(ue.Status) 140 | return ctx.SendString(ue.Error()) 141 | } 142 | 143 | func getCurrentUserID(ctx *fiber.Ctx) string { 144 | if x := ctx.Locals(userIDLocalKey); x != nil { 145 | s, ok := x.(string) 146 | if ok { 147 | return s 148 | } 149 | } 150 | return "" 151 | } 152 | 153 | func requestStandardSignIn(ctx *fiber.Ctx) error { 154 | rdu := ctx.OriginalURL() // TODO: Could this use of OriginalURL be insecure? 155 | 156 | queryParams := make(url.Values) 157 | queryParams.Add("problem", "Please sign in first.") 158 | queryParams.Add("next", rdu) 159 | nextURL := urls.AuthSignIn + "?" + queryParams.Encode() 160 | 161 | ctx.Status(fiber.StatusUnauthorized) 162 | 163 | // Instead of plainly redirecting, we use a HTML redirect here. This is to clear the HTTP verb used for this 164 | // request. For example - if the request was made with DELETE, using ctx.Redirect will preserve that verb. Using 165 | // this method will restart with a GET verb. 166 | return views.SendPage(ctx, &views.PolyPage{ 167 | TitleString: "Please sign in first", 168 | BodyContent: daz.H("p", "Please sign in first. If your browser doesn't automatically redirect you, click ", daz.H("a", daz.Attr{"href": nextURL}, "here"), ".")(), 169 | ExtraHeadContent: daz.H("meta", daz.Attr{"http-equiv": "Refresh", "content": "0; " + nextURL})(), 170 | }) 171 | } 172 | 173 | func requestFragmentSignIn(ctx *fiber.Ctx, nextURL string) error { 174 | queryParams := make(url.Values) 175 | queryParams.Add("problem", "Please sign in first.") 176 | queryParams.Add("next", nextURL) 177 | 178 | ctx.Set("HX-Redirect", urls.AuthSignIn+"?"+queryParams.Encode()) 179 | return nil 180 | } 181 | 182 | func fragmentEmitSuccess(ctx *fiber.Ctx) { 183 | ctx.Set("HX-Trigger", "successResponse") 184 | } 185 | -------------------------------------------------------------------------------- /walrss/internal/http/mainpage.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/core" 5 | "github.com/codemicro/walrss/walrss/internal/http/views" 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | func (s *Server) mainPage(ctx *fiber.Ctx) error { 10 | currentUserID := getCurrentUserID(ctx) 11 | if currentUserID == "" { 12 | return requestStandardSignIn(ctx) 13 | } 14 | 15 | user, err := core.GetUserByID(s.state, currentUserID) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | feeds, err := core.GetFeedsForUser(s.state, currentUserID) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return views.SendPage(ctx, &views.MainPage{ 26 | EnableDigests: user.Active, 27 | SelectedDay: user.ScheduleDay, 28 | SelectedTime: user.ScheduleHour, 29 | Feeds: feeds, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /walrss/internal/http/new.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/core" 5 | "github.com/codemicro/walrss/walrss/internal/http/views" 6 | "github.com/codemicro/walrss/walrss/internal/urls" 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | func (s *Server) newFeedItem(ctx *fiber.Ctx) error { 11 | 12 | currentUserID := getCurrentUserID(ctx) 13 | if currentUserID == "" { 14 | return requestFragmentSignIn(ctx, urls.Index) 15 | } 16 | 17 | switch ctx.Method() { 18 | case fiber.MethodGet: 19 | return ctx.SendString(views.RenderNewFeedItemRow()) 20 | case fiber.MethodPost: 21 | feed, err := core.NewFeed( 22 | s.state, 23 | currentUserID, 24 | ctx.FormValue("name"), 25 | ctx.FormValue("url"), 26 | ) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return ctx.SendString(views.RenderFeedRow(feed.ID, feed.Name, feed.URL)) 32 | } 33 | 34 | panic("unreachable") 35 | } 36 | -------------------------------------------------------------------------------- /walrss/internal/http/testEmail.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/core" 5 | "github.com/codemicro/walrss/walrss/internal/http/views" 6 | "github.com/codemicro/walrss/walrss/internal/rss" 7 | "github.com/codemicro/walrss/walrss/internal/urls" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/rs/zerolog/log" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | func (s *Server) sendTestEmail(ctx *fiber.Ctx) error { 17 | currentUserID := getCurrentUserID(ctx) 18 | if currentUserID == "" { 19 | return requestFragmentSignIn(ctx, urls.Index) 20 | } 21 | 22 | user, err := core.GetUserByID(s.state, currentUserID) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | testEmailStatesLock.Lock() 28 | testEmailStates[currentUserID] = testEmailStates[currentUserID] + "Starting at " + time.Now().UTC().Format(time.RFC1123Z) + "\n" 29 | testEmailStatesLock.Unlock() 30 | 31 | go func() { 32 | status := make(chan string, 50) 33 | 34 | var err error 35 | go func() { 36 | err = rss.ProcessUserFeed(s.state, user, status, true) 37 | }() 38 | 39 | for statusAddition := range status { 40 | testEmailStatesLock.Lock() 41 | testEmailStates[currentUserID] = testEmailStates[currentUserID] + statusAddition + "\n" 42 | testEmailStatesLock.Unlock() 43 | } 44 | 45 | if err != nil { 46 | log.Error().Err(err).Str("location", "test email").Str("user", user.ID).Send() 47 | } 48 | }() 49 | 50 | return s.testEmailStatus(ctx) 51 | } 52 | 53 | var ( 54 | testEmailStates = make(map[string]string) 55 | testEmailStatesLock sync.RWMutex 56 | ) 57 | 58 | func (s *Server) testEmailStatus(ctx *fiber.Ctx) error { 59 | currentUserID := getCurrentUserID(ctx) 60 | if currentUserID == "" { 61 | return requestFragmentSignIn(ctx, urls.Index) 62 | } 63 | 64 | end, _ := strconv.ParseBool(ctx.Query("end", "false")) 65 | 66 | testEmailStatesLock.RLock() 67 | var content string 68 | if end { 69 | testEmailStatesLock.RUnlock() 70 | testEmailStatesLock.Lock() 71 | delete(testEmailStates, currentUserID) 72 | testEmailStatesLock.Unlock() 73 | } else { 74 | content = testEmailStates[currentUserID] 75 | testEmailStatesLock.RUnlock() 76 | } 77 | 78 | if end { 79 | ctx.Set("HX-Refresh", "true") 80 | return nil 81 | } 82 | 83 | var endOnNext bool 84 | if strings.HasSuffix(strings.TrimSpace(content), "Done!") { 85 | endOnNext = true 86 | fragmentEmitSuccess(ctx) 87 | } 88 | return ctx.SendString(views.RenderTestEmailBox(content, endOnNext)) 89 | } 90 | -------------------------------------------------------------------------------- /walrss/internal/http/views/layoutComponents.qtpl.html: -------------------------------------------------------------------------------- 1 | {% import "github.com/codemicro/walrss/walrss/internal/urls" %} 2 | 3 | {% func navbar() %} 4 | 9 | {% endfunc %} -------------------------------------------------------------------------------- /walrss/internal/http/views/layoutComponents.qtpl.html.go: -------------------------------------------------------------------------------- 1 | // Code generated by qtc from "layoutComponents.qtpl.html". DO NOT EDIT. 2 | // See https://github.com/valyala/quicktemplate for details. 3 | 4 | package views 5 | 6 | import "github.com/codemicro/walrss/walrss/internal/urls" 7 | 8 | import ( 9 | qtio422016 "io" 10 | 11 | qt422016 "github.com/valyala/quicktemplate" 12 | ) 13 | 14 | var ( 15 | _ = qtio422016.Copy 16 | _ = qt422016.AcquireByteBuffer 17 | ) 18 | 19 | func streamnavbar(qw422016 *qt422016.Writer) { 20 | qw422016.N().S(` 21 | 28 | `) 29 | } 30 | 31 | func writenavbar(qq422016 qtio422016.Writer) { 32 | qw422016 := qt422016.AcquireWriter(qq422016) 33 | streamnavbar(qw422016) 34 | qt422016.ReleaseWriter(qw422016) 35 | } 36 | 37 | func navbar() string { 38 | qb422016 := qt422016.AcquireByteBuffer() 39 | writenavbar(qb422016) 40 | qs422016 := string(qb422016.B) 41 | qt422016.ReleaseByteBuffer(qb422016) 42 | return qs422016 43 | } 44 | -------------------------------------------------------------------------------- /walrss/internal/http/views/main.qtpl.html: -------------------------------------------------------------------------------- 1 | {% import "github.com/codemicro/walrss/walrss/internal/db" %} 2 | {% import "github.com/codemicro/walrss/walrss/internal/urls" %} 3 | {% import "sort" %} 4 | {% import "strings" %} 5 | {% import "github.com/lithammer/shortuuid/v4" %} 6 | 7 | {% code type MainPage struct { 8 | BasePage 9 | EnableDigests bool 10 | SelectedDay db.SendDay 11 | SelectedTime int 12 | Feeds db.FeedSlice 13 | } %} 14 | 15 | {% func (p *MainPage) Title() %}{% endfunc %} 16 | {% func (p *MainPage) Body() %} 17 |
18 | 26 |
27 | 28 | {%= navbar() %} 29 | 30 | 59 | 60 |
61 |

My settings

62 | 63 | {%= p.RenderScheduleCard() %} 64 | 65 |
66 |
67 | Feeds 68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 89 | 90 | 91 | 92 | {% code sort.Sort(p.Feeds) %} 93 | {% for _, feed := range p.Feeds %} 94 | {%= RenderFeedRow(feed.ID, feed.Name, feed.URL) %} 95 | {% endfor %} 96 | 97 |
NameURL 77 |
78 | 87 |
88 |
98 | 99 |
100 |
101 | 102 |
103 |
104 | Import/export 105 |
106 |
107 | 108 |
109 | 110 |
111 | Export (OPML) 112 |
113 | 114 |
115 | 118 | 119 | 122 |
123 | 124 |
125 |
126 | Loading... 127 |
128 |
129 | 130 |
131 | 132 | 145 | 146 |
147 |
148 | 149 |
150 | {% endfunc %} 151 | 152 | {% func RenderFeedRow(id, name, url string) %} 153 | 154 | {%s name %} 155 | {%s url %} 156 | 157 |
158 | 161 | 169 |
170 | 171 | 172 | {% endfunc %} 173 | 174 | {% func RenderFeedEditRow(id, name, url string) %} 175 | 176 | 183 | 190 | 191 |
192 | 200 | 201 |
202 | 203 | 204 | {% endfunc %} 205 | 206 | {% func RenderNewFeedItemRow() %} 207 | {% code id := shortuuid.New() %} 208 | 209 | 216 | 223 | 224 |
225 | 232 | 233 |
234 | 235 | 236 | 241 | 242 | {% endfunc %} 243 | 244 | {% func (p *MainPage) RenderScheduleCard() %} 245 |
246 |
247 | Email settings 248 |
249 |
250 | 251 |
252 |
253 | 261 | 262 |
263 | 264 |
265 |
266 | Loading... 267 |
268 |
269 |
270 | 271 |
276 |
277 | Deliver my digests 278 |
279 | 280 |
281 | 282 | 297 |
298 | 299 |
at
300 | 301 |
302 | 303 | 318 |
319 | 320 |
UTC
321 | 322 |
323 | 324 |
325 | 326 |
327 |
328 | Loading... 329 |
330 |
331 |
332 | 333 | 334 | 335 |
336 |
337 | {% endfunc %} 338 | 339 | {% func RenderTestEmailBox(content string, endOnNext bool) %} 340 | {% code 341 | url := urls.TestEmailStatus 342 | if endOnNext { 343 | url += "?end=true" 344 | } 345 | parts := strings.Split(strings.TrimSpace(content), "\n") 346 | if len(parts) > 7 { 347 | parts = parts[len(parts)-7:] 348 | } 349 | %} 350 |
351 |

Test status

352 | 353 | {% for _, line := range parts %} 354 | {%s line %}
355 | {% endfor %} 356 |
357 | {% endfunc %} -------------------------------------------------------------------------------- /walrss/internal/http/views/main.qtpl.html.go: -------------------------------------------------------------------------------- 1 | // Code generated by qtc from "main.qtpl.html". DO NOT EDIT. 2 | // See https://github.com/valyala/quicktemplate for details. 3 | 4 | package views 5 | 6 | import "github.com/codemicro/walrss/walrss/internal/db" 7 | 8 | import "github.com/codemicro/walrss/walrss/internal/urls" 9 | 10 | import "sort" 11 | 12 | import "strings" 13 | 14 | import "github.com/lithammer/shortuuid/v4" 15 | 16 | import ( 17 | qtio422016 "io" 18 | 19 | qt422016 "github.com/valyala/quicktemplate" 20 | ) 21 | 22 | var ( 23 | _ = qtio422016.Copy 24 | _ = qt422016.AcquireByteBuffer 25 | ) 26 | 27 | type MainPage struct { 28 | BasePage 29 | EnableDigests bool 30 | SelectedDay db.SendDay 31 | SelectedTime int 32 | Feeds db.FeedSlice 33 | } 34 | 35 | func (p *MainPage) StreamTitle(qw422016 *qt422016.Writer) { 36 | } 37 | 38 | func (p *MainPage) WriteTitle(qq422016 qtio422016.Writer) { 39 | qw422016 := qt422016.AcquireWriter(qq422016) 40 | p.StreamTitle(qw422016) 41 | qt422016.ReleaseWriter(qw422016) 42 | } 43 | 44 | func (p *MainPage) Title() string { 45 | qb422016 := qt422016.AcquireByteBuffer() 46 | p.WriteTitle(qb422016) 47 | qs422016 := string(qb422016.B) 48 | qt422016.ReleaseByteBuffer(qb422016) 49 | return qs422016 50 | } 51 | 52 | func (p *MainPage) StreamBody(qw422016 *qt422016.Writer) { 53 | qw422016.N().S(` 54 |
55 | 63 |
64 | 65 | `) 66 | streamnavbar(qw422016) 67 | qw422016.N().S(` 68 | 69 | 98 | 99 |
100 |

My settings

101 | 102 | `) 103 | p.StreamRenderScheduleCard(qw422016) 104 | qw422016.N().S(` 105 | 106 |
107 |
108 | Feeds 109 |
110 |
111 | 112 | 113 | 114 | 115 | 116 | 117 | 132 | 133 | 134 | 135 | `) 136 | sort.Sort(p.Feeds) 137 | 138 | qw422016.N().S(` 139 | `) 140 | for _, feed := range p.Feeds { 141 | qw422016.N().S(` 142 | `) 143 | StreamRenderFeedRow(qw422016, feed.ID, feed.Name, feed.URL) 144 | qw422016.N().S(` 145 | `) 146 | } 147 | qw422016.N().S(` 148 | 149 |
NameURL 118 |
119 | 130 |
131 |
150 | 151 |
152 |
153 | 154 |
155 |
156 | Import/export 157 |
158 |
159 | 160 |
161 | 162 |
163 | Export (OPML) 166 |
167 | 168 |
169 | 172 | 173 | 178 |
179 | 180 |
181 |
182 | Loading... 183 |
184 |
185 | 186 |
187 | 188 | 201 | 202 |
203 |
204 | 205 |
206 | `) 207 | } 208 | 209 | func (p *MainPage) WriteBody(qq422016 qtio422016.Writer) { 210 | qw422016 := qt422016.AcquireWriter(qq422016) 211 | p.StreamBody(qw422016) 212 | qt422016.ReleaseWriter(qw422016) 213 | } 214 | 215 | func (p *MainPage) Body() string { 216 | qb422016 := qt422016.AcquireByteBuffer() 217 | p.WriteBody(qb422016) 218 | qs422016 := string(qb422016.B) 219 | qt422016.ReleaseByteBuffer(qb422016) 220 | return qs422016 221 | } 222 | 223 | func StreamRenderFeedRow(qw422016 *qt422016.Writer, id, name, url string) { 224 | qw422016.N().S(` 225 | 228 | `) 231 | qw422016.E().S(name) 232 | qw422016.N().S(` 233 | `) 236 | qw422016.E().S(url) 237 | qw422016.N().S(` 238 | 239 |
240 | 245 | 255 |
256 | 257 | 258 | `) 259 | } 260 | 261 | func WriteRenderFeedRow(qq422016 qtio422016.Writer, id, name, url string) { 262 | qw422016 := qt422016.AcquireWriter(qq422016) 263 | StreamRenderFeedRow(qw422016, id, name, url) 264 | qt422016.ReleaseWriter(qw422016) 265 | } 266 | 267 | func RenderFeedRow(id, name, url string) string { 268 | qb422016 := qt422016.AcquireByteBuffer() 269 | WriteRenderFeedRow(qb422016, id, name, url) 270 | qs422016 := string(qb422016.B) 271 | qt422016.ReleaseByteBuffer(qb422016) 272 | return qs422016 273 | } 274 | 275 | func StreamRenderFeedEditRow(qw422016 *qt422016.Writer, id, name, url string) { 276 | qw422016.N().S(` 277 | 280 | 291 | 304 | 305 |
306 | 320 | 323 |
324 | 325 | 326 | `) 327 | } 328 | 329 | func WriteRenderFeedEditRow(qq422016 qtio422016.Writer, id, name, url string) { 330 | qw422016 := qt422016.AcquireWriter(qq422016) 331 | StreamRenderFeedEditRow(qw422016, id, name, url) 332 | qt422016.ReleaseWriter(qw422016) 333 | } 334 | 335 | func RenderFeedEditRow(id, name, url string) string { 336 | qb422016 := qt422016.AcquireByteBuffer() 337 | WriteRenderFeedEditRow(qb422016, id, name, url) 338 | qs422016 := string(qb422016.B) 339 | qt422016.ReleaseByteBuffer(qb422016) 340 | return qs422016 341 | } 342 | 343 | func StreamRenderNewFeedItemRow(qw422016 *qt422016.Writer) { 344 | qw422016.N().S(` 345 | `) 346 | id := shortuuid.New() 347 | 348 | qw422016.N().S(` 349 | 352 | 361 | 370 | 371 |
372 | 385 | 388 |
389 | 390 | 391 | 400 | 401 | `) 402 | } 403 | 404 | func WriteRenderNewFeedItemRow(qq422016 qtio422016.Writer) { 405 | qw422016 := qt422016.AcquireWriter(qq422016) 406 | StreamRenderNewFeedItemRow(qw422016) 407 | qt422016.ReleaseWriter(qw422016) 408 | } 409 | 410 | func RenderNewFeedItemRow() string { 411 | qb422016 := qt422016.AcquireByteBuffer() 412 | WriteRenderNewFeedItemRow(qb422016) 413 | qs422016 := string(qb422016.B) 414 | qt422016.ReleaseByteBuffer(qb422016) 415 | return qs422016 416 | } 417 | 418 | func (p *MainPage) StreamRenderScheduleCard(qw422016 *qt422016.Writer) { 419 | qw422016.N().S(` 420 |
421 |
422 | Email settings 423 |
424 |
425 | 426 |
427 |
428 | 442 | 443 |
444 | 445 |
446 |
447 | Loading... 448 |
449 |
450 |
451 | 452 |
459 |
460 | Deliver my digests 461 |
462 | 463 |
464 | 465 | 499 |
500 | 501 |
at
502 | 503 |
504 | 505 | 536 |
537 | 538 |
UTC
539 | 540 |
541 | 546 |
547 | 548 |
549 |
550 | Loading... 551 |
552 |
553 |
554 | 555 | 558 | 559 |
560 |
561 | `) 562 | } 563 | 564 | func (p *MainPage) WriteRenderScheduleCard(qq422016 qtio422016.Writer) { 565 | qw422016 := qt422016.AcquireWriter(qq422016) 566 | p.StreamRenderScheduleCard(qw422016) 567 | qt422016.ReleaseWriter(qw422016) 568 | } 569 | 570 | func (p *MainPage) RenderScheduleCard() string { 571 | qb422016 := qt422016.AcquireByteBuffer() 572 | p.WriteRenderScheduleCard(qb422016) 573 | qs422016 := string(qb422016.B) 574 | qt422016.ReleaseByteBuffer(qb422016) 575 | return qs422016 576 | } 577 | 578 | func StreamRenderTestEmailBox(qw422016 *qt422016.Writer, content string, endOnNext bool) { 579 | qw422016.N().S(` 580 | `) 581 | url := urls.TestEmailStatus 582 | if endOnNext { 583 | url += "?end=true" 584 | } 585 | parts := strings.Split(strings.TrimSpace(content), "\n") 586 | if len(parts) > 7 { 587 | parts = parts[len(parts)-7:] 588 | } 589 | 590 | qw422016.N().S(` 591 |
594 |

Test status

595 | 596 | `) 597 | for _, line := range parts { 598 | qw422016.N().S(` 599 | `) 600 | qw422016.E().S(line) 601 | qw422016.N().S(`
602 | `) 603 | } 604 | qw422016.N().S(` 605 |
606 | `) 607 | } 608 | 609 | func WriteRenderTestEmailBox(qq422016 qtio422016.Writer, content string, endOnNext bool) { 610 | qw422016 := qt422016.AcquireWriter(qq422016) 611 | StreamRenderTestEmailBox(qw422016, content, endOnNext) 612 | qt422016.ReleaseWriter(qw422016) 613 | } 614 | 615 | func RenderTestEmailBox(content string, endOnNext bool) string { 616 | qb422016 := qt422016.AcquireByteBuffer() 617 | WriteRenderTestEmailBox(qb422016, content, endOnNext) 618 | qs422016 := string(qb422016.B) 619 | qt422016.ReleaseByteBuffer(qb422016) 620 | return qs422016 621 | } 622 | -------------------------------------------------------------------------------- /walrss/internal/http/views/page.qtpl.html: -------------------------------------------------------------------------------- 1 | {% interface 2 | Page { 3 | Title() 4 | Body() 5 | HeadContent() 6 | } 7 | %} 8 | 9 | Page prints a page implementing Page interface. 10 | {% func RenderPage(p Page) %} 11 | 12 | 13 | 14 | {%s= makePageTitle(p) %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 37 | {%= p.HeadContent() %} 38 | 39 | 40 | {%= p.Body() %} 41 |
42 |

Walrss is open-source software that's licensed under the GNU AGPL v3.

43 | 44 | 45 | {% endfunc %} 46 | 47 | {% code type BasePage struct {} %} 48 | {% func (p *BasePage) Title() %}{% endfunc %} 49 | {% func (p *BasePage) HeadContent() %}{% endfunc %} 50 | 51 | {% func ProblemBox(p string) %} 52 | 55 | {% endfunc %} 56 | 57 | {% func SuccessBox(p string) %} 58 | 61 | {% endfunc %} 62 | 63 | {% func WarningBox(p string) %} 64 | 67 | {% endfunc %} 68 | 69 | PolyPage is used to create a basic page dynamically using Daz. 70 | {% code 71 | type PolyPage struct { 72 | BasePage 73 | TitleString string 74 | BodyContent string 75 | ExtraHeadContent string 76 | } 77 | %} 78 | 79 | {% func (p *PolyPage) Title() %}{%s= p.TitleString %}{% endfunc %} 80 | {% func (p *PolyPage) Body() %}{%= navbar() %}
{%s= p.BodyContent %}
{% endfunc %} 81 | {% func (p *PolyPage) HeadContent() %}{%s= p.ExtraHeadContent %}{% endfunc %} -------------------------------------------------------------------------------- /walrss/internal/http/views/page.qtpl.html.go: -------------------------------------------------------------------------------- 1 | // Code generated by qtc from "page.qtpl.html". DO NOT EDIT. 2 | // See https://github.com/valyala/quicktemplate for details. 3 | 4 | package views 5 | 6 | import ( 7 | qtio422016 "io" 8 | 9 | qt422016 "github.com/valyala/quicktemplate" 10 | ) 11 | 12 | var ( 13 | _ = qtio422016.Copy 14 | _ = qt422016.AcquireByteBuffer 15 | ) 16 | 17 | type Page interface { 18 | Title() string 19 | StreamTitle(qw422016 *qt422016.Writer) 20 | WriteTitle(qq422016 qtio422016.Writer) 21 | Body() string 22 | StreamBody(qw422016 *qt422016.Writer) 23 | WriteBody(qq422016 qtio422016.Writer) 24 | HeadContent() string 25 | StreamHeadContent(qw422016 *qt422016.Writer) 26 | WriteHeadContent(qq422016 qtio422016.Writer) 27 | } 28 | 29 | // Page prints a page implementing Page interface. 30 | 31 | func StreamRenderPage(qw422016 *qt422016.Writer, p Page) { 32 | qw422016.N().S(` 33 | 34 | 35 | 36 | `) 37 | qw422016.N().S(makePageTitle(p)) 38 | qw422016.N().S(` 39 | 40 | 41 | 42 | 43 | 44 | 45 | 61 | `) 62 | p.StreamHeadContent(qw422016) 63 | qw422016.N().S(` 64 | 65 | 66 | `) 67 | p.StreamBody(qw422016) 68 | qw422016.N().S(` 69 |
70 |

Walrss is open-source software that's licensed under the GNU AGPL v3.

71 | 72 | 73 | `) 74 | } 75 | 76 | func WriteRenderPage(qq422016 qtio422016.Writer, p Page) { 77 | qw422016 := qt422016.AcquireWriter(qq422016) 78 | StreamRenderPage(qw422016, p) 79 | qt422016.ReleaseWriter(qw422016) 80 | } 81 | 82 | func RenderPage(p Page) string { 83 | qb422016 := qt422016.AcquireByteBuffer() 84 | WriteRenderPage(qb422016, p) 85 | qs422016 := string(qb422016.B) 86 | qt422016.ReleaseByteBuffer(qb422016) 87 | return qs422016 88 | } 89 | 90 | type BasePage struct{} 91 | 92 | func (p *BasePage) StreamTitle(qw422016 *qt422016.Writer) { 93 | } 94 | 95 | func (p *BasePage) WriteTitle(qq422016 qtio422016.Writer) { 96 | qw422016 := qt422016.AcquireWriter(qq422016) 97 | p.StreamTitle(qw422016) 98 | qt422016.ReleaseWriter(qw422016) 99 | } 100 | 101 | func (p *BasePage) Title() string { 102 | qb422016 := qt422016.AcquireByteBuffer() 103 | p.WriteTitle(qb422016) 104 | qs422016 := string(qb422016.B) 105 | qt422016.ReleaseByteBuffer(qb422016) 106 | return qs422016 107 | } 108 | 109 | func (p *BasePage) StreamHeadContent(qw422016 *qt422016.Writer) { 110 | } 111 | 112 | func (p *BasePage) WriteHeadContent(qq422016 qtio422016.Writer) { 113 | qw422016 := qt422016.AcquireWriter(qq422016) 114 | p.StreamHeadContent(qw422016) 115 | qt422016.ReleaseWriter(qw422016) 116 | } 117 | 118 | func (p *BasePage) HeadContent() string { 119 | qb422016 := qt422016.AcquireByteBuffer() 120 | p.WriteHeadContent(qb422016) 121 | qs422016 := string(qb422016.B) 122 | qt422016.ReleaseByteBuffer(qb422016) 123 | return qs422016 124 | } 125 | 126 | func StreamProblemBox(qw422016 *qt422016.Writer, p string) { 127 | qw422016.N().S(` 128 | 133 | `) 134 | } 135 | 136 | func WriteProblemBox(qq422016 qtio422016.Writer, p string) { 137 | qw422016 := qt422016.AcquireWriter(qq422016) 138 | StreamProblemBox(qw422016, p) 139 | qt422016.ReleaseWriter(qw422016) 140 | } 141 | 142 | func ProblemBox(p string) string { 143 | qb422016 := qt422016.AcquireByteBuffer() 144 | WriteProblemBox(qb422016, p) 145 | qs422016 := string(qb422016.B) 146 | qt422016.ReleaseByteBuffer(qb422016) 147 | return qs422016 148 | } 149 | 150 | func StreamSuccessBox(qw422016 *qt422016.Writer, p string) { 151 | qw422016.N().S(` 152 | 157 | `) 158 | } 159 | 160 | func WriteSuccessBox(qq422016 qtio422016.Writer, p string) { 161 | qw422016 := qt422016.AcquireWriter(qq422016) 162 | StreamSuccessBox(qw422016, p) 163 | qt422016.ReleaseWriter(qw422016) 164 | } 165 | 166 | func SuccessBox(p string) string { 167 | qb422016 := qt422016.AcquireByteBuffer() 168 | WriteSuccessBox(qb422016, p) 169 | qs422016 := string(qb422016.B) 170 | qt422016.ReleaseByteBuffer(qb422016) 171 | return qs422016 172 | } 173 | 174 | func StreamWarningBox(qw422016 *qt422016.Writer, p string) { 175 | qw422016.N().S(` 176 | 181 | `) 182 | } 183 | 184 | func WriteWarningBox(qq422016 qtio422016.Writer, p string) { 185 | qw422016 := qt422016.AcquireWriter(qq422016) 186 | StreamWarningBox(qw422016, p) 187 | qt422016.ReleaseWriter(qw422016) 188 | } 189 | 190 | func WarningBox(p string) string { 191 | qb422016 := qt422016.AcquireByteBuffer() 192 | WriteWarningBox(qb422016, p) 193 | qs422016 := string(qb422016.B) 194 | qt422016.ReleaseByteBuffer(qb422016) 195 | return qs422016 196 | } 197 | 198 | // PolyPage is used to create a basic page dynamically using Daz. 199 | 200 | type PolyPage struct { 201 | BasePage 202 | TitleString string 203 | BodyContent string 204 | ExtraHeadContent string 205 | } 206 | 207 | func (p *PolyPage) StreamTitle(qw422016 *qt422016.Writer) { 208 | qw422016.N().S(p.TitleString) 209 | } 210 | 211 | func (p *PolyPage) WriteTitle(qq422016 qtio422016.Writer) { 212 | qw422016 := qt422016.AcquireWriter(qq422016) 213 | p.StreamTitle(qw422016) 214 | qt422016.ReleaseWriter(qw422016) 215 | } 216 | 217 | func (p *PolyPage) Title() string { 218 | qb422016 := qt422016.AcquireByteBuffer() 219 | p.WriteTitle(qb422016) 220 | qs422016 := string(qb422016.B) 221 | qt422016.ReleaseByteBuffer(qb422016) 222 | return qs422016 223 | } 224 | 225 | func (p *PolyPage) StreamBody(qw422016 *qt422016.Writer) { 226 | streamnavbar(qw422016) 227 | qw422016.N().S(`
`) 228 | qw422016.N().S(p.BodyContent) 229 | qw422016.N().S(`
`) 230 | } 231 | 232 | func (p *PolyPage) WriteBody(qq422016 qtio422016.Writer) { 233 | qw422016 := qt422016.AcquireWriter(qq422016) 234 | p.StreamBody(qw422016) 235 | qt422016.ReleaseWriter(qw422016) 236 | } 237 | 238 | func (p *PolyPage) Body() string { 239 | qb422016 := qt422016.AcquireByteBuffer() 240 | p.WriteBody(qb422016) 241 | qs422016 := string(qb422016.B) 242 | qt422016.ReleaseByteBuffer(qb422016) 243 | return qs422016 244 | } 245 | 246 | func (p *PolyPage) StreamHeadContent(qw422016 *qt422016.Writer) { 247 | qw422016.N().S(p.ExtraHeadContent) 248 | } 249 | 250 | func (p *PolyPage) WriteHeadContent(qq422016 qtio422016.Writer) { 251 | qw422016 := qt422016.AcquireWriter(qq422016) 252 | p.StreamHeadContent(qw422016) 253 | qt422016.ReleaseWriter(qw422016) 254 | } 255 | 256 | func (p *PolyPage) HeadContent() string { 257 | qb422016 := qt422016.AcquireByteBuffer() 258 | p.WriteHeadContent(qb422016) 259 | qs422016 := string(qb422016.B) 260 | qt422016.ReleaseByteBuffer(qb422016) 261 | return qs422016 262 | } 263 | -------------------------------------------------------------------------------- /walrss/internal/http/views/register.qtpl.html: -------------------------------------------------------------------------------- 1 | {% import "github.com/codemicro/walrss/walrss/internal/urls" %} 2 | 3 | {% code type RegisterPage struct { 4 | BasePage 5 | Problem string 6 | } %} 7 | 8 | {% func (p *RegisterPage) Title() %}Register{% endfunc %} 9 | {% func (p *RegisterPage) Body() %} 10 | {%= navbar() %} 11 | 12 |
13 |

Register

14 | 15 | {% if p.Problem != "" %} 16 | {%= ProblemBox(p.Problem) %} 17 | {% endif %} 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 | 33 |
34 |
35 | Already got an account? Click here to sign in 36 |
37 | {% endfunc %} -------------------------------------------------------------------------------- /walrss/internal/http/views/register.qtpl.html.go: -------------------------------------------------------------------------------- 1 | // Code generated by qtc from "register.qtpl.html". DO NOT EDIT. 2 | // See https://github.com/valyala/quicktemplate for details. 3 | 4 | package views 5 | 6 | import "github.com/codemicro/walrss/walrss/internal/urls" 7 | 8 | import ( 9 | qtio422016 "io" 10 | 11 | qt422016 "github.com/valyala/quicktemplate" 12 | ) 13 | 14 | var ( 15 | _ = qtio422016.Copy 16 | _ = qt422016.AcquireByteBuffer 17 | ) 18 | 19 | type RegisterPage struct { 20 | BasePage 21 | Problem string 22 | } 23 | 24 | func (p *RegisterPage) StreamTitle(qw422016 *qt422016.Writer) { 25 | qw422016.N().S(`Register`) 26 | } 27 | 28 | func (p *RegisterPage) WriteTitle(qq422016 qtio422016.Writer) { 29 | qw422016 := qt422016.AcquireWriter(qq422016) 30 | p.StreamTitle(qw422016) 31 | qt422016.ReleaseWriter(qw422016) 32 | } 33 | 34 | func (p *RegisterPage) Title() string { 35 | qb422016 := qt422016.AcquireByteBuffer() 36 | p.WriteTitle(qb422016) 37 | qs422016 := string(qb422016.B) 38 | qt422016.ReleaseByteBuffer(qb422016) 39 | return qs422016 40 | } 41 | 42 | func (p *RegisterPage) StreamBody(qw422016 *qt422016.Writer) { 43 | qw422016.N().S(` 44 | `) 45 | streamnavbar(qw422016) 46 | qw422016.N().S(` 47 | 48 |
49 |

Register

50 | 51 | `) 52 | if p.Problem != "" { 53 | qw422016.N().S(` 54 | `) 55 | StreamProblemBox(qw422016, p.Problem) 56 | qw422016.N().S(` 57 | `) 58 | } 59 | qw422016.N().S(` 60 | 61 |
62 |
63 | 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 | 75 |
76 |
77 | Already got an account? Click here to sign in 80 |
81 | `) 82 | } 83 | 84 | func (p *RegisterPage) WriteBody(qq422016 qtio422016.Writer) { 85 | qw422016 := qt422016.AcquireWriter(qq422016) 86 | p.StreamBody(qw422016) 87 | qt422016.ReleaseWriter(qw422016) 88 | } 89 | 90 | func (p *RegisterPage) Body() string { 91 | qb422016 := qt422016.AcquireByteBuffer() 92 | p.WriteBody(qb422016) 93 | qs422016 := string(qb422016.B) 94 | qt422016.ReleaseByteBuffer(qb422016) 95 | return qs422016 96 | } 97 | -------------------------------------------------------------------------------- /walrss/internal/http/views/signin.qtpl.html: -------------------------------------------------------------------------------- 1 | {% import "github.com/codemicro/walrss/walrss/internal/urls" %} 2 | 3 | {% code type SignInPage struct { 4 | BasePage 5 | Problem string 6 | OIDCEnabled bool 7 | } %} 8 | 9 | {% func (p *SignInPage) Title() %}Sign in{% endfunc %} 10 | {% func (p *SignInPage) Body() %} 11 | {%= navbar() %} 12 | 13 |
14 |

Sign in

15 | 16 | {% if p.Problem != "" %} 17 | {%= ProblemBox(p.Problem) %} 18 | {% endif %} 19 | 20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 | 30 |
31 |
32 | No account? Click here to register 33 |
34 | {% if p.OIDCEnabled %} 35 | Click here to login with OIDC 36 | {% endif %} 37 |
38 | {% endfunc %} -------------------------------------------------------------------------------- /walrss/internal/http/views/signin.qtpl.html.go: -------------------------------------------------------------------------------- 1 | // Code generated by qtc from "signin.qtpl.html". DO NOT EDIT. 2 | // See https://github.com/valyala/quicktemplate for details. 3 | 4 | package views 5 | 6 | import "github.com/codemicro/walrss/walrss/internal/urls" 7 | 8 | import ( 9 | qtio422016 "io" 10 | 11 | qt422016 "github.com/valyala/quicktemplate" 12 | ) 13 | 14 | var ( 15 | _ = qtio422016.Copy 16 | _ = qt422016.AcquireByteBuffer 17 | ) 18 | 19 | type SignInPage struct { 20 | BasePage 21 | Problem string 22 | OIDCEnabled bool 23 | } 24 | 25 | func (p *SignInPage) StreamTitle(qw422016 *qt422016.Writer) { 26 | qw422016.N().S(`Sign in`) 27 | } 28 | 29 | func (p *SignInPage) WriteTitle(qq422016 qtio422016.Writer) { 30 | qw422016 := qt422016.AcquireWriter(qq422016) 31 | p.StreamTitle(qw422016) 32 | qt422016.ReleaseWriter(qw422016) 33 | } 34 | 35 | func (p *SignInPage) Title() string { 36 | qb422016 := qt422016.AcquireByteBuffer() 37 | p.WriteTitle(qb422016) 38 | qs422016 := string(qb422016.B) 39 | qt422016.ReleaseByteBuffer(qb422016) 40 | return qs422016 41 | } 42 | 43 | func (p *SignInPage) StreamBody(qw422016 *qt422016.Writer) { 44 | qw422016.N().S(` 45 | `) 46 | streamnavbar(qw422016) 47 | qw422016.N().S(` 48 | 49 |
50 |

Sign in

51 | 52 | `) 53 | if p.Problem != "" { 54 | qw422016.N().S(` 55 | `) 56 | StreamProblemBox(qw422016, p.Problem) 57 | qw422016.N().S(` 58 | `) 59 | } 60 | qw422016.N().S(` 61 | 62 |
63 |
64 | 65 | 66 |
67 |
68 | 69 | 70 |
71 | 72 |
73 |
74 | No account? Click here to register 77 |
78 | `) 79 | if p.OIDCEnabled { 80 | qw422016.N().S(` 81 | Click here to login with OIDC 84 | `) 85 | } 86 | qw422016.N().S(` 87 |
88 | `) 89 | } 90 | 91 | func (p *SignInPage) WriteBody(qq422016 qtio422016.Writer) { 92 | qw422016 := qt422016.AcquireWriter(qq422016) 93 | p.StreamBody(qw422016) 94 | qt422016.ReleaseWriter(qw422016) 95 | } 96 | 97 | func (p *SignInPage) Body() string { 98 | qb422016 := qt422016.AcquireByteBuffer() 99 | p.WriteBody(qb422016) 100 | qs422016 := string(qb422016.B) 101 | qt422016.ReleaseByteBuffer(qb422016) 102 | return qs422016 103 | } 104 | -------------------------------------------------------------------------------- /walrss/internal/http/views/views.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | //go:generate go install github.com/valyala/quicktemplate/qtc@latest 6 | //go:generate qtc -skipLineComments -ext qtpl.html 7 | 8 | func SendPage(ctx *fiber.Ctx, page Page) error { 9 | ctx.Set(fiber.HeaderContentType, "html") 10 | return ctx.SendString(RenderPage(page)) 11 | } 12 | 13 | func makePageTitle(p Page) string { 14 | t := p.Title() 15 | if t == "" { 16 | return "Walrss" 17 | } 18 | return t + " | Walrss" 19 | } 20 | -------------------------------------------------------------------------------- /walrss/internal/rss/processor.go: -------------------------------------------------------------------------------- 1 | package rss 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "fmt" 8 | "github.com/carlmjohnson/requests" 9 | "github.com/codemicro/walrss/walrss/internal/core" 10 | "github.com/codemicro/walrss/walrss/internal/db" 11 | "github.com/codemicro/walrss/walrss/internal/state" 12 | "github.com/codemicro/walrss/walrss/internal/urls" 13 | "github.com/jordan-wright/email" 14 | "github.com/matcornic/hermes" 15 | "github.com/mmcdole/gofeed" 16 | "github.com/rs/zerolog/log" 17 | "net/http" 18 | "net/smtp" 19 | "net/textproto" 20 | "sort" 21 | "strings" 22 | "sync" 23 | "time" 24 | ) 25 | 26 | const ( 27 | dateFormat = "02Jan06" 28 | timeFormat = "15:04:05" 29 | ) 30 | 31 | var ua = struct { 32 | ua string 33 | once *sync.Once 34 | }{"", new(sync.Once)} 35 | 36 | func getUserAgent(st *state.State) string { 37 | ua.once.Do(func() { 38 | o := "walrss" 39 | if st.Config.Debug { 40 | o += "/DEV" 41 | } else if core.Version != "" { 42 | o += "/" + core.Version 43 | } 44 | 45 | var parts []string 46 | if st.Config.Platform.ContactInformation != "" { 47 | parts = append(parts, st.Config.Platform.ContactInformation) 48 | } 49 | parts = append(parts, "https://github.com/codemicro/walrss") 50 | 51 | o += " (" + strings.Join(parts, ", ") + ")" 52 | ua.ua = o 53 | }) 54 | return ua.ua 55 | } 56 | 57 | type processedFeed struct { 58 | Name string 59 | Items []*feedItem 60 | Error error 61 | } 62 | 63 | func ProcessFeeds(st *state.State, day db.SendDay, hour int) error { 64 | u, err := core.GetUsersBySchedule(st, day, hour) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | for _, ur := range u { 70 | if err := ProcessUserFeed(st, ur, nil, false); err != nil { 71 | log.Warn().Err(err).Str("user", ur.ID).Msg("could not process feeds for user") 72 | } 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func reportProgress(channel chan string, msg string) { 79 | if channel == nil { 80 | return 81 | } 82 | channel <- msg 83 | } 84 | 85 | func ProcessUserFeed(st *state.State, user *db.User, progressChannel chan string, isTest bool) error { 86 | defer func() { 87 | if progressChannel == nil { 88 | return 89 | } 90 | close(progressChannel) // This is important! There's a chance that if this is not done before ProcessUserFeed 91 | // exits, the caller completely hang on this thread. 92 | }() 93 | 94 | reportProgress(progressChannel, "Fetching feed list") 95 | userFeeds, err := core.GetFeedsForUser(st, user.ID) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | var interval time.Duration 101 | if user.ScheduleDay == db.SendDaily || user.ScheduleDay == db.SendDayNever { 102 | interval = time.Hour * 24 103 | } else { 104 | interval = time.Hour * 24 * 7 105 | } 106 | 107 | var processedFeeds []*processedFeed 108 | 109 | startTime := time.Now().UTC() 110 | 111 | reportProgress(progressChannel, "Fetching feed content") 112 | 113 | for i, f := range userFeeds { 114 | reportProgress(progressChannel, fmt.Sprintf("Fetching feed %d of %d: %s", i+1, len(userFeeds), f.Name)) 115 | pf := new(processedFeed) 116 | pf.Name = f.Name 117 | 118 | rawFeed, err := getFeedContent(st, f) 119 | if err != nil { 120 | pf.Error = err 121 | reportProgress(progressChannel, "Failed to fetch: "+err.Error()) 122 | } else { 123 | ffcRes, err := filterFeedContent( 124 | st, 125 | interval, 126 | rawFeed, 127 | f.ID, 128 | ) 129 | if err != nil { 130 | return fmt.Errorf("filter for new feed items in %s: %w", f.ID, err) 131 | } 132 | 133 | pf.Items = ffcRes.filtered 134 | 135 | // add new items to DB cache when we're not testing. if we're testing, adding known items to the cache will 136 | // stop them from appearing in any scheduled emails later on. 137 | if !isTest { 138 | var newItems []*db.FeedItem 139 | for _, i := range ffcRes.new { 140 | newItems = append(newItems, &db.FeedItem{ 141 | FeedID: f.ID, 142 | ItemID: i.ID, 143 | }) 144 | } 145 | if err := core.NewFeedItems(st, newItems); err != nil { 146 | return fmt.Errorf("insert new feed items for feed %s: %w", f.ID, err) 147 | } 148 | } 149 | } 150 | processedFeeds = append(processedFeeds, pf) 151 | } 152 | 153 | reportProgress(progressChannel, "Finished fetching feed content\nGenerating email") 154 | 155 | plainContent, htmlContent, err := generateEmail(st, processedFeeds, interval, time.Now().UTC().Sub(startTime)) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | reportProgress(progressChannel, "Sending email") 161 | 162 | err = sendEmail( 163 | st, 164 | plainContent, 165 | htmlContent, 166 | user.Email, 167 | "RSS digest for "+time.Now().UTC().Format(dateFormat), 168 | ) 169 | 170 | reportProgress(progressChannel, "Done!") 171 | 172 | return err 173 | } 174 | 175 | var feedFetchLock = new(sync.Mutex) 176 | 177 | func getFeedContent(st *state.State, f *db.Feed) (*gofeed.Feed, error) { 178 | feedFetchLock.Lock() // I would like to be able to get rid of this lock, however, in order to do so, a lot of the 179 | // database infrastructure needs removing and rewriting to use proper transactions. So we'll leave it here for now. 180 | defer feedFetchLock.Unlock() 181 | 182 | buf := new(bytes.Buffer) 183 | 184 | // If a feed was cached in the last hour, Walrss will not re-query the remote server and will just use the cache. 185 | hasCachedFeed := f.CachedContent != "" 186 | cachedFeedIsFresh := !f.LastFetched.IsZero() && time.Now().UTC().Sub(f.LastFetched) < time.Hour 187 | 188 | if hasCachedFeed && cachedFeedIsFresh { 189 | log.Debug().Msgf("%s using fresh cache (%v)", f.URL, f.LastFetched) 190 | buf.WriteString(f.CachedContent) 191 | } else { 192 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 193 | defer cancel() 194 | 195 | var notModified bool 196 | headers := make(textproto.MIMEHeader) 197 | 198 | requestBuilder := requests.URL(f.URL).ToBytesBuffer(buf).UserAgent(getUserAgent(st)).CopyHeaders(headers) 199 | 200 | if f.LastEtag != "" || f.LastModified != "" { 201 | requestBuilder.AddValidator( 202 | func(resp *http.Response) error { 203 | if resp.StatusCode == http.StatusNotModified { 204 | notModified = true 205 | return nil 206 | } else { 207 | return requests.DefaultValidator(resp) 208 | } 209 | }, 210 | ) 211 | 212 | if f.LastEtag != "" { 213 | requestBuilder.Header("If-None-Match", f.LastEtag) 214 | } else if f.LastModified != "" { 215 | requestBuilder.Header("If-Modified-Since", f.LastModified) 216 | } 217 | 218 | } else { 219 | requestBuilder.AddValidator(requests.DefaultValidator) // Since we're using CopyHeaders, we need to add the 220 | // default validator back ourselves. 221 | } 222 | 223 | if err := requestBuilder.Fetch(ctx); err != nil { 224 | return nil, err 225 | } 226 | 227 | f.LastFetched = time.Now().UTC() 228 | 229 | if notModified { 230 | log.Debug().Msgf("%s not modified", f.URL) 231 | buf.WriteString(f.CachedContent) 232 | } else if etag := headers.Get("ETag"); etag != "" { 233 | log.Debug().Msgf("%s modified (ETag)", f.URL) 234 | f.SetCacheWithEtag(etag, buf.String()) 235 | } else if lastModified := headers.Get("Last-Modified"); lastModified != "" { 236 | log.Debug().Msgf("%s modified (Last-Modified)", f.URL) 237 | f.SetCacheWithLastModified(lastModified, buf.String()) 238 | } 239 | 240 | if err := core.UpdateFeed(st, f); err != nil { 241 | return nil, fmt.Errorf("update feed after fetch: %v", err) 242 | } 243 | } 244 | 245 | feed, err := gofeed.NewParser().Parse(buf) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | return feed, nil 251 | } 252 | 253 | type feedItem struct { 254 | ID string 255 | Title string 256 | URL string 257 | PublishTime time.Time 258 | } 259 | 260 | type filterFeedContentResult struct { 261 | // filtered are the items that should be shown to a user in an email 262 | filtered []*feedItem 263 | 264 | // items are the items that have never been seen before and should be added to the database. 265 | // 266 | // items in new may only have the ID field set. 267 | new []*feedItem 268 | 269 | // filtered and new may differ when using time-based intervals and only a recently-published portion of the overall 270 | // new set should be shown to the user. 271 | } 272 | 273 | func filterFeedContent(st *state.State, interval time.Duration, feed *gofeed.Feed, feedID string) (*filterFeedContentResult, error) { 274 | knownItemsList, err := core.GetFeedItemsForFeed(st, feedID) 275 | if err != nil { 276 | return nil, fmt.Errorf("get known feed items: %w", err) 277 | } 278 | 279 | knownItems := make(map[string]struct{}) 280 | for _, i := range knownItemsList { 281 | knownItems[i.ItemID] = struct{}{} 282 | } 283 | 284 | res := new(filterFeedContentResult) 285 | 286 | if len(knownItemsList) == 0 { 287 | // This might happen in the case of database migrations or when a new feed is added. 288 | // In this instance, we'll fall back to the old behaviour of using a time interval to select new posts. 289 | 290 | // Intervals are in terms of days, never hours. 291 | // This gets a data for the previous day/week and sets it to the start of that day. 292 | 293 | selectPublishedSince := time.Now().UTC().Add(-interval) 294 | selectPublishedSince = time.Date(selectPublishedSince.Year(), selectPublishedSince.Month(), selectPublishedSince.Day(), 0, 0, 0, 0, time.UTC) 295 | 296 | for _, item := range feed.Items { 297 | res.new = append(res.new, &feedItem{ID: item.GUID}) 298 | 299 | if item.PublishedParsed == nil { 300 | continue 301 | } 302 | 303 | if item.PublishedParsed.After(selectPublishedSince) || item.PublishedParsed.Equal(selectPublishedSince) { 304 | if item.PublishedParsed == nil { 305 | item.PublishedParsed = &time.Time{} 306 | } 307 | 308 | res.filtered = append(res.filtered, &feedItem{ 309 | ID: item.GUID, 310 | Title: strings.TrimSpace(item.Title), 311 | URL: item.Link, 312 | PublishTime: *item.PublishedParsed, 313 | }) 314 | } 315 | } 316 | 317 | } else { 318 | for _, item := range feed.Items { 319 | if _, found := knownItems[item.GUID]; !found { 320 | if item.PublishedParsed == nil { 321 | item.PublishedParsed = &time.Time{} 322 | } 323 | 324 | x := &feedItem{ 325 | ID: item.GUID, 326 | Title: strings.TrimSpace(item.Title), 327 | URL: item.Link, 328 | PublishTime: *item.PublishedParsed, 329 | } 330 | 331 | res.new = append(res.new, x) 332 | res.filtered = append(res.filtered, x) 333 | } 334 | } 335 | } 336 | 337 | return res, nil 338 | } 339 | 340 | func generateEmail(st *state.State, processedItems []*processedFeed, interval, timeToGenerate time.Duration) (plain, html []byte, err error) { 341 | sort.Slice(processedItems, func(i, j int) bool { 342 | pi, pj := processedItems[i], processedItems[j] 343 | 344 | if pi.Error != nil && pj.Error == nil { 345 | return false 346 | } 347 | 348 | if pi.Error == nil && pj.Error != nil { 349 | return true 350 | } 351 | 352 | return pi.Name < pj.Name 353 | }) 354 | 355 | var sb strings.Builder 356 | 357 | sb.WriteString("Here are the updates to the feeds you're subscribed to that have been published in the last ") 358 | 359 | if interval.Hours() == 24 { 360 | sb.WriteString("24 hours") 361 | } else { 362 | sb.WriteString(fmt.Sprintf("%.0f days", interval.Hours()/24)) 363 | } 364 | 365 | sb.WriteString(".\n\n") 366 | 367 | if len(processedItems) == 0 { 368 | sb.WriteString("*There's nothing to show here right now.*\n\n") 369 | } 370 | 371 | for _, processedItem := range processedItems { 372 | 373 | if len(processedItem.Items) != 0 || processedItem.Error != nil { 374 | sb.WriteString("* **") 375 | sb.WriteString(strings.ReplaceAll(processedItem.Name, "*", `\*`)) 376 | sb.WriteString("**\n") 377 | } 378 | 379 | if processedItem.Error != nil { 380 | sb.WriteString(" * **Error:** ") 381 | sb.WriteString(processedItem.Error.Error()) 382 | sb.WriteString("\n") 383 | } else { 384 | r := strings.NewReplacer("[", `\[`, "]", `\]`, "*", `\*`) 385 | 386 | for _, item := range processedItem.Items { 387 | sb.WriteString(" * [**") 388 | sb.WriteString(r.Replace(item.Title)) 389 | sb.WriteString("**](") 390 | sb.WriteString(item.URL) 391 | sb.WriteString(") - ") 392 | sb.WriteString(item.PublishTime.Format(dateFormat + " " + timeFormat)) 393 | sb.WriteString("\n") 394 | } 395 | 396 | } 397 | } 398 | 399 | e := hermes.Email{ 400 | Body: hermes.Body{ 401 | Title: "Hi there!", 402 | Signature: "Have a good one", 403 | Outros: []string{ 404 | "You can edit the feeds that you're subscribed to and your delivery settings here: " + st.Config.Server.ExternalURL, 405 | }, 406 | FreeMarkdown: hermes.Markdown(sb.String()), 407 | }, 408 | } 409 | 410 | var versionSpecifier string 411 | if core.Version != "" { 412 | versionSpecifier = " v" + core.Version 413 | } 414 | 415 | renderer := hermes.Hermes{ 416 | Product: hermes.Product{ 417 | Name: "Walrss", 418 | Link: st.Config.Server.ExternalURL, 419 | Logo: st.Config.Server.ExternalURL + urls.Statics + "/logo_light.png", 420 | Copyright: fmt.Sprintf("This email was generated in %.2f seconds by Walrss"+versionSpecifier+" - Walrss is open source software licensed under the GNU AGPL v3 - https://github.com/codemicro/walrss", timeToGenerate.Seconds()), 421 | }, 422 | Theme: new(hermes.Flat), 423 | } 424 | 425 | plainString, err := renderer.GeneratePlainText(e) 426 | if err != nil { 427 | return nil, nil, err 428 | } 429 | 430 | htmlString, err := renderer.GenerateHTML(e) 431 | if err != nil { 432 | return nil, nil, err 433 | } 434 | 435 | return []byte(plainString), []byte(htmlString), nil 436 | } 437 | 438 | func sendEmail(st *state.State, plain, html []byte, to, subject string) error { 439 | if st.Config.Debug { 440 | log.Debug().Str("addr", to).Str("subject", subject).Msg("skipping email send due to debug mode") 441 | return nil 442 | } 443 | 444 | e := &email.Email{ 445 | From: st.Config.Email.From, 446 | To: []string{to}, 447 | Subject: subject, 448 | Text: plain, 449 | HTML: html, 450 | } 451 | 452 | var smtpAuth smtp.Auth 453 | if st.Config.Email.Username != "" || st.Config.Email.Password != "" { 454 | smtpAuth = smtp.PlainAuth("", st.Config.Email.Username, st.Config.Email.Password, st.Config.Email.Host) 455 | } 456 | 457 | smtpAddr := fmt.Sprintf("%s:%d", st.Config.Email.Host, st.Config.Email.Port) 458 | 459 | var sendFn func(*email.Email) error 460 | 461 | switch st.Config.Email.TLS { 462 | case "no": 463 | sendFn = func(e *email.Email) error { 464 | return e.Send(smtpAddr, smtpAuth) 465 | } 466 | case "tls": 467 | sendFn = func(e *email.Email) error { 468 | return e.SendWithTLS(smtpAddr, smtpAuth, &tls.Config{ 469 | ServerName: st.Config.Email.Host, 470 | }) 471 | } 472 | case "starttls": 473 | sendFn = func(e *email.Email) error { 474 | return e.SendWithStartTLS(smtpAddr, smtpAuth, &tls.Config{ 475 | ServerName: st.Config.Email.Host, 476 | }) 477 | } 478 | default: 479 | return fmt.Errorf("unknown TLS option %s", st.Config.Email.TLS) 480 | } 481 | 482 | return sendFn(e) 483 | } 484 | -------------------------------------------------------------------------------- /walrss/internal/rss/watcher.go: -------------------------------------------------------------------------------- 1 | package rss 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/db" 5 | "github.com/codemicro/walrss/walrss/internal/state" 6 | "github.com/rs/zerolog/log" 7 | "time" 8 | ) 9 | 10 | func StartWatcher(st *state.State) { 11 | log.Debug().Str("location", "feed watcher").Msg("starting feed watcher") 12 | go func() { 13 | timeUntilNextHour := time.Minute * time.Duration(60-time.Now().UTC().Minute()) 14 | timeUntilNextHour += 30 * time.Second // little bit of buffer time to 15 | // make sure we're actually going to be within in the new hour 16 | 17 | log.Debug().Str("location", "feed watcher").Msgf("waiting %.2f minutes before starting ticker", timeUntilNextHour.Minutes()) 18 | 19 | time.Sleep(timeUntilNextHour) 20 | 21 | runFeedProcessor(st, time.Now().UTC()) 22 | 23 | log.Debug().Str("location", "feed watcher").Msg("starting ticker") 24 | 25 | ticker := time.NewTicker(time.Hour) 26 | for range ticker.C { 27 | // Yes, I am aware that you can get the current time from ticker.C 28 | // BUT that's been weird and caused some issues resulting in an 29 | // hour's task not being run, so I'm not using it 30 | runFeedProcessor(st, time.Now().UTC()) 31 | } 32 | }() 33 | } 34 | 35 | func runFeedProcessor(st *state.State, currentTime time.Time) { 36 | currentTime = currentTime.UTC() 37 | log.Debug(). 38 | Str("location", "feed watcher"). 39 | Str("day", db.SendDayFromWeekday(currentTime.Weekday()).String()). 40 | Int("hour", currentTime.Hour()). 41 | Msg("running hourly job") 42 | if err := ProcessFeeds(st, db.SendDayFromWeekday(currentTime.Weekday()), currentTime.Hour()); err != nil { 43 | log.Error().Err(err).Str("location", "feed watcher").Send() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /walrss/internal/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/kkyr/fig" 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | "github.com/uptrace/bun" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | type State struct { 15 | Config *Config 16 | Data *bun.DB 17 | } 18 | 19 | func New() *State { 20 | return &State{} 21 | } 22 | 23 | type Config struct { 24 | Email struct { 25 | Host string `fig:"host" validate:"required"` 26 | Port int `fig:"port" validate:"required"` 27 | Username string `fig:"username"` 28 | Password string `fig:"password"` 29 | From string `fig:"from" validate:"required"` 30 | TLS string `fig:"tls" default:"starttls"` 31 | } 32 | Server struct { 33 | Host string `fig:"host" default:"127.0.0.1"` 34 | Port int `fig:"port" default:"8080"` 35 | ExternalURL string `fig:"externalURL" validate:"required"` 36 | } 37 | Platform struct { 38 | DisableRegistration bool `fig:"disableRegistration"` 39 | DisableSecureCookies bool `fig:"disableSecureCookies"` 40 | ContactInformation string `fig:"contactInformation"` 41 | } 42 | OIDC struct { 43 | Enable bool `fig:"enable"` 44 | ClientID string `fig:"clientID"` 45 | ClientSecret string `fig:"clientSecret"` 46 | Issuer string `fig:"issuer"` 47 | } 48 | Debug bool `fig:"debug"` 49 | } 50 | 51 | const configFilename = "config.yaml" 52 | 53 | func LoadConfig() (*Config, error) { 54 | // If the file doesn't exist, Fig will throw a hissy fit, so we should create a blank one if it doesn't exist 55 | if _, err := os.Stat(configFilename); err != nil { 56 | if errors.Is(err, os.ErrNotExist) { 57 | // If the file doesn't have contents, Fig will throw an EOF, despite `touch config.yaml` working fine. idk lol 58 | if err := os.WriteFile(configFilename, []byte("{}"), 0777); err != nil { 59 | return nil, err 60 | } 61 | } else { 62 | return nil, err 63 | } 64 | } 65 | 66 | cfg := new(Config) 67 | if err := fig.Load(cfg); err != nil { 68 | return nil, err 69 | } 70 | 71 | cfg.Server.ExternalURL = strings.TrimSuffix(cfg.Server.ExternalURL, "/") 72 | 73 | if cfg.Email.TLS != "tls" && cfg.Email.TLS != "starttls" && cfg.Email.TLS != "no" { 74 | return nil, errors.New("invalid email.tls value: must be 'starttls', 'tls' or 'no'") 75 | } 76 | 77 | if !cfg.Debug { 78 | log.Logger = log.Logger.Level(zerolog.InfoLevel) 79 | } 80 | 81 | return cfg, nil 82 | } 83 | 84 | func (cfg *Config) GetHTTPAddress() string { 85 | return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) 86 | } 87 | 88 | func (cfg *Config) EnableSecureCookies() bool { 89 | if cfg.Debug { 90 | return false 91 | } 92 | return !cfg.Platform.DisableSecureCookies 93 | } 94 | -------------------------------------------------------------------------------- /walrss/internal/static/assets/logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/walrss/internal/static/assets/logo_dark.png -------------------------------------------------------------------------------- /walrss/internal/static/assets/logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemicro/walrss/1c542bd35b0e5f6ca4e4b92cc849bd652b901f8d/walrss/internal/static/assets/logo_light.png -------------------------------------------------------------------------------- /walrss/internal/static/static.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/gofiber/fiber/v2/middleware/filesystem" 9 | ) 10 | 11 | //go:embed assets 12 | var assets embed.FS 13 | 14 | func NewHandler() fiber.Handler { 15 | return filesystem.New(filesystem.Config{ 16 | Root: http.FS(assets), 17 | PathPrefix: "assets", 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /walrss/internal/urls/urls.go: -------------------------------------------------------------------------------- 1 | package urls 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | Index = "/" 10 | 11 | Auth = "/auth" 12 | AuthSignIn = Auth + "/signin" 13 | AuthRegister = Auth + "/register" 14 | AuthOIDC = Auth + "/oidc" 15 | AuthOIDCOutbound = AuthOIDC + "/outbound" 16 | AuthOIDCCallback = AuthOIDC + "/callback" 17 | 18 | Edit = "/edit" 19 | EditEnabledState = Edit + "/enabled" 20 | EditTimings = Edit + "/timings" 21 | EditFeedItem = Edit + "/feed/:id" 22 | CancelEditFeedItem = Edit + "/feed/:id/cancel" 23 | 24 | Export = "/export" 25 | ExportAsOPML = Export + "/opml" 26 | 27 | Import = "/import" 28 | ImportFromOPML = Import + "/opml" 29 | 30 | New = "/new" 31 | NewFeedItem = New + "/feed" 32 | 33 | SendTestEmail = "/send/test" 34 | TestEmailStatus = SendTestEmail + "/status" 35 | 36 | Statics = "/statics" 37 | ) 38 | 39 | func Expand(template string, replacements ...interface{}) string { 40 | spt := strings.Split(template, "/") 41 | for i, part := range spt { 42 | if len(part) == 0 { 43 | continue 44 | } 45 | if part[0] == ':' { 46 | spt[i] = "%s" 47 | } 48 | } 49 | return fmt.Sprintf(strings.Join(spt, "/"), replacements...) 50 | } 51 | -------------------------------------------------------------------------------- /walrss/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/codemicro/walrss/walrss/internal/db" 5 | "github.com/codemicro/walrss/walrss/internal/http" 6 | "github.com/codemicro/walrss/walrss/internal/rss" 7 | "github.com/codemicro/walrss/walrss/internal/state" 8 | "github.com/rs/zerolog/log" 9 | "github.com/uptrace/bun/extra/bundebug" 10 | "os" 11 | ) 12 | 13 | const dbFilename = "walrss.db" 14 | const walrssDirectoryEnv = "WALRSS_DIR" 15 | 16 | func run() error { 17 | if err := switchToDataDirectory(); err != nil { 18 | return err 19 | } 20 | 21 | st := state.New() 22 | if config, err := state.LoadConfig(); err != nil { 23 | return err 24 | } else { 25 | st.Config = config 26 | } 27 | 28 | store, err := db.New(dbFilename) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | store.AddQueryHook(bundebug.NewQueryHook( 34 | bundebug.WithEnabled(st.Config.Debug), 35 | )) 36 | 37 | if err := db.DoMigrations(store); err != nil { 38 | return err 39 | } 40 | 41 | st.Data = store 42 | 43 | server, err := http.New(st) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | rss.StartWatcher(st) 49 | 50 | log.Info().Msg("starting server on " + st.Config.GetHTTPAddress()) 51 | 52 | return server.Run() 53 | } 54 | 55 | func main() { 56 | if err := run(); err != nil { 57 | log.Fatal().Err(err).Msg("could not start") 58 | } 59 | } 60 | 61 | func switchToDataDirectory() error { 62 | if dir := os.Getenv(walrssDirectoryEnv); dir != "" { 63 | return os.Chdir(dir) 64 | } 65 | return nil 66 | } 67 | --------------------------------------------------------------------------------