aboutsummaryrefslogtreecommitdiff
path: root/forged/internal
diff options
context:
space:
mode:
Diffstat (limited to 'forged/internal')
-rw-r--r--forged/internal/common/ansiec/colors.go (renamed from forged/internal/ansiec/colors.go)20
-rw-r--r--forged/internal/common/ansiec/doc.go (renamed from forged/internal/ansiec/ansiec.go)0
-rw-r--r--forged/internal/common/ansiec/reset.go (renamed from forged/internal/ansiec/reset.go)1
-rw-r--r--forged/internal/common/ansiec/style.go (renamed from forged/internal/ansiec/style.go)1
-rw-r--r--forged/internal/common/argon2id/LICENSE18
-rw-r--r--forged/internal/common/argon2id/argon2id.go (renamed from forged/internal/argon2id/argon2id.go)0
-rw-r--r--forged/internal/common/bare/LICENSE203
-rw-r--r--forged/internal/common/bare/doc.go (renamed from forged/internal/bare/package.go)0
-rw-r--r--forged/internal/common/bare/errors.go (renamed from forged/internal/bare/errors.go)0
-rw-r--r--forged/internal/common/bare/limit.go (renamed from forged/internal/bare/limit.go)0
-rw-r--r--forged/internal/common/bare/marshal.go (renamed from forged/internal/bare/marshal.go)0
-rw-r--r--forged/internal/common/bare/reader.go (renamed from forged/internal/bare/reader.go)2
-rw-r--r--forged/internal/common/bare/unions.go (renamed from forged/internal/bare/unions.go)6
-rw-r--r--forged/internal/common/bare/unmarshal.go (renamed from forged/internal/bare/unmarshal.go)0
-rw-r--r--forged/internal/common/bare/varint.go (renamed from forged/internal/bare/varint.go)0
-rw-r--r--forged/internal/common/bare/writer.go (renamed from forged/internal/bare/writer.go)2
-rw-r--r--forged/internal/common/cmap/LICENSE22
-rw-r--r--forged/internal/common/cmap/comparable_map.go (renamed from forged/internal/cmap/comparable_map.go)2
-rw-r--r--forged/internal/common/cmap/map.go (renamed from forged/internal/cmap/map.go)2
-rw-r--r--forged/internal/common/humanize/bytes.go (renamed from forged/internal/humanize/bytes.go)0
-rw-r--r--forged/internal/common/misc/back.go (renamed from forged/internal/misc/back.go)0
-rw-r--r--forged/internal/common/misc/iter.go (renamed from forged/internal/misc/iter.go)0
-rw-r--r--forged/internal/common/misc/misc.go (renamed from forged/internal/unsorted/version.go)5
-rw-r--r--forged/internal/common/misc/net.go42
-rw-r--r--forged/internal/common/misc/slices.go (renamed from forged/internal/misc/misc.go)1
-rw-r--r--forged/internal/common/misc/trivial.go (renamed from forged/internal/misc/trivial.go)4
-rw-r--r--forged/internal/common/misc/unsafe.go (renamed from forged/internal/misc/unsafe.go)4
-rw-r--r--forged/internal/common/misc/url.go (renamed from forged/internal/misc/url.go)0
-rw-r--r--forged/internal/common/scfg/.golangci.yaml (renamed from forged/internal/scfg/.golangci.yaml)0
-rw-r--r--forged/internal/common/scfg/LICENSE18
-rw-r--r--forged/internal/common/scfg/reader.go (renamed from forged/internal/scfg/reader.go)0
-rw-r--r--forged/internal/common/scfg/scfg.go (renamed from forged/internal/scfg/scfg.go)0
-rw-r--r--forged/internal/common/scfg/struct.go (renamed from forged/internal/scfg/struct.go)0
-rw-r--r--forged/internal/common/scfg/unmarshal.go (renamed from forged/internal/scfg/unmarshal.go)0
-rw-r--r--forged/internal/common/scfg/writer.go (renamed from forged/internal/scfg/writer.go)0
-rw-r--r--forged/internal/config/config.go61
-rw-r--r--forged/internal/database/config.go5
-rw-r--r--forged/internal/database/database.go8
-rw-r--r--forged/internal/database/queries/.gitignore1
-rw-r--r--forged/internal/embed/.gitignore6
-rw-r--r--forged/internal/embed/embed.go20
-rw-r--r--forged/internal/global/global.go8
-rw-r--r--forged/internal/incoming/hooks/config.go6
-rw-r--r--forged/internal/incoming/hooks/hooks.go80
-rw-r--r--forged/internal/incoming/lmtp/config.go9
-rw-r--r--forged/internal/incoming/lmtp/lmtp.go70
-rw-r--r--forged/internal/incoming/ssh/config.go9
-rw-r--r--forged/internal/incoming/ssh/ssh.go89
-rw-r--r--forged/internal/incoming/web/authn.go33
-rw-r--r--forged/internal/incoming/web/config.go16
-rw-r--r--forged/internal/incoming/web/handler.go77
-rw-r--r--forged/internal/incoming/web/handlers/group.go92
-rw-r--r--forged/internal/incoming/web/handlers/index.go40
-rw-r--r--forged/internal/incoming/web/handlers/not_implemented.go22
-rw-r--r--forged/internal/incoming/web/handlers/repo/handler.go15
-rw-r--r--forged/internal/incoming/web/handlers/repo/index.go20
-rw-r--r--forged/internal/incoming/web/handlers/repo/raw.go19
-rw-r--r--forged/internal/incoming/web/handlers/repo/tree.go19
-rw-r--r--forged/internal/incoming/web/handlers/special/login.go115
-rw-r--r--forged/internal/incoming/web/router.go428
-rw-r--r--forged/internal/incoming/web/server.go70
-rw-r--r--forged/internal/incoming/web/templates/load.go31
-rw-r--r--forged/internal/incoming/web/templates/renderer.go23
-rw-r--r--forged/internal/incoming/web/types/types.go45
-rw-r--r--forged/internal/ipc/git2c/client.go (renamed from forged/internal/git2c/client.go)16
-rw-r--r--forged/internal/ipc/git2c/cmd_index.go (renamed from forged/internal/git2c/cmd_index.go)6
-rw-r--r--forged/internal/ipc/git2c/cmd_treeraw.go (renamed from forged/internal/git2c/cmd_treeraw.go)9
-rw-r--r--forged/internal/ipc/git2c/doc.go2
-rw-r--r--forged/internal/ipc/git2c/git_types.go (renamed from forged/internal/git2c/git_types.go)0
-rw-r--r--forged/internal/ipc/git2c/perror.go (renamed from forged/internal/git2c/perror.go)3
-rw-r--r--forged/internal/ipc/irc/bot.go (renamed from forged/internal/irc/bot.go)38
-rw-r--r--forged/internal/ipc/irc/config.go13
-rw-r--r--forged/internal/ipc/irc/conn.go (renamed from forged/internal/irc/conn.go)15
-rw-r--r--forged/internal/ipc/irc/doc.go2
-rw-r--r--forged/internal/ipc/irc/errors.go (renamed from forged/internal/irc/errors.go)0
-rw-r--r--forged/internal/ipc/irc/message.go (renamed from forged/internal/irc/message.go)20
-rw-r--r--forged/internal/ipc/irc/source.go (renamed from forged/internal/irc/source.go)3
-rw-r--r--forged/internal/misc/deploy.go22
-rw-r--r--forged/internal/misc/panic.go19
-rw-r--r--forged/internal/oldgit/fmtpatch.go56
-rw-r--r--forged/internal/oldgit/oldgit.go5
-rw-r--r--forged/internal/oldgit/patch.go43
-rw-r--r--forged/internal/render/chroma.go41
-rw-r--r--forged/internal/render/escape.go14
-rw-r--r--forged/internal/render/readme.go34
-rw-r--r--forged/internal/render/render.go5
-rw-r--r--forged/internal/server/server.go82
-rw-r--r--forged/internal/unsorted/acl.go59
-rw-r--r--forged/internal/unsorted/config.go94
-rw-r--r--forged/internal/unsorted/database.go43
-rw-r--r--forged/internal/unsorted/fedauth.go97
-rw-r--r--forged/internal/unsorted/git_hooks_handle_linux.go377
-rw-r--r--forged/internal/unsorted/git_hooks_handle_other.go336
-rw-r--r--forged/internal/unsorted/git_init.go34
-rw-r--r--forged/internal/unsorted/git_misc.go95
-rw-r--r--forged/internal/unsorted/git_plumbing.go188
-rw-r--r--forged/internal/unsorted/git_ref.go37
-rw-r--r--forged/internal/unsorted/http_auth.go26
-rw-r--r--forged/internal/unsorted/http_handle_branches.go46
-rw-r--r--forged/internal/unsorted/http_handle_group_index.go196
-rw-r--r--forged/internal/unsorted/http_handle_index.go26
-rw-r--r--forged/internal/unsorted/http_handle_login.go108
-rw-r--r--forged/internal/unsorted/http_handle_repo_commit.go146
-rw-r--r--forged/internal/unsorted/http_handle_repo_contrib_index.go52
-rw-r--r--forged/internal/unsorted/http_handle_repo_contrib_one.go98
-rw-r--r--forged/internal/unsorted/http_handle_repo_index.go41
-rw-r--r--forged/internal/unsorted/http_handle_repo_info.go107
-rw-r--r--forged/internal/unsorted/http_handle_repo_log.go39
-rw-r--r--forged/internal/unsorted/http_handle_repo_raw.go56
-rw-r--r--forged/internal/unsorted/http_handle_repo_tree.go55
-rw-r--r--forged/internal/unsorted/http_handle_repo_upload_pack.go120
-rw-r--r--forged/internal/unsorted/http_handle_users.go15
-rw-r--r--forged/internal/unsorted/http_server.go276
-rw-r--r--forged/internal/unsorted/http_template.go18
-rw-r--r--forged/internal/unsorted/lmtp_handle_patch.go133
-rw-r--r--forged/internal/unsorted/lmtp_server.go204
-rw-r--r--forged/internal/unsorted/remote_url.go25
-rw-r--r--forged/internal/unsorted/resources.go56
-rw-r--r--forged/internal/unsorted/server.go236
-rw-r--r--forged/internal/unsorted/ssh_handle_receive_pack.go131
-rw-r--r--forged/internal/unsorted/ssh_handle_upload_pack.go39
-rw-r--r--forged/internal/unsorted/ssh_server.go96
-rw-r--r--forged/internal/unsorted/ssh_utils.go79
-rw-r--r--forged/internal/unsorted/unsorted.go5
-rw-r--r--forged/internal/unsorted/users.go35
-rw-r--r--forged/internal/web/error_pages.go60
-rw-r--r--forged/internal/web/web.go5
127 files changed, 1897 insertions, 4230 deletions
diff --git a/forged/internal/ansiec/colors.go b/forged/internal/common/ansiec/colors.go
index 8e5f54b..8be2a0c 100644
--- a/forged/internal/ansiec/colors.go
+++ b/forged/internal/common/ansiec/colors.go
@@ -3,18 +3,16 @@
package ansiec
+// ANSI color codes
const (
- Black = "\x1b[30m"
- Red = "\x1b[31m"
- Green = "\x1b[32m"
- Yellow = "\x1b[33m"
- Blue = "\x1b[34m"
- Magenta = "\x1b[35m"
- Cyan = "\x1b[36m"
- White = "\x1b[37m"
-)
-
-const (
+ Black = "\x1b[30m"
+ Red = "\x1b[31m"
+ Green = "\x1b[32m"
+ Yellow = "\x1b[33m"
+ Blue = "\x1b[34m"
+ Magenta = "\x1b[35m"
+ Cyan = "\x1b[36m"
+ White = "\x1b[37m"
BrightBlack = "\x1b[30;1m"
BrightRed = "\x1b[31;1m"
BrightGreen = "\x1b[32;1m"
diff --git a/forged/internal/ansiec/ansiec.go b/forged/internal/common/ansiec/doc.go
index 542c564..542c564 100644
--- a/forged/internal/ansiec/ansiec.go
+++ b/forged/internal/common/ansiec/doc.go
diff --git a/forged/internal/ansiec/reset.go b/forged/internal/common/ansiec/reset.go
index c5b6ba6..51bb312 100644
--- a/forged/internal/ansiec/reset.go
+++ b/forged/internal/common/ansiec/reset.go
@@ -3,4 +3,5 @@
package ansiec
+// Reset the colors and styles
const Reset = "\x1b[0m"
diff --git a/forged/internal/ansiec/style.go b/forged/internal/common/ansiec/style.go
index dd37344..95edbbe 100644
--- a/forged/internal/ansiec/style.go
+++ b/forged/internal/common/ansiec/style.go
@@ -3,6 +3,7 @@
package ansiec
+// ANSI text styles
const (
Bold = "\x1b[1m"
Underline = "\x1b[4m"
diff --git a/forged/internal/common/argon2id/LICENSE b/forged/internal/common/argon2id/LICENSE
new file mode 100644
index 0000000..3649823
--- /dev/null
+++ b/forged/internal/common/argon2id/LICENSE
@@ -0,0 +1,18 @@
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/forged/internal/argon2id/argon2id.go b/forged/internal/common/argon2id/argon2id.go
index 88df8f6..88df8f6 100644
--- a/forged/internal/argon2id/argon2id.go
+++ b/forged/internal/common/argon2id/argon2id.go
diff --git a/forged/internal/common/bare/LICENSE b/forged/internal/common/bare/LICENSE
new file mode 100644
index 0000000..6b0b127
--- /dev/null
+++ b/forged/internal/common/bare/LICENSE
@@ -0,0 +1,203 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/forged/internal/bare/package.go b/forged/internal/common/bare/doc.go
index 2f12f55..2f12f55 100644
--- a/forged/internal/bare/package.go
+++ b/forged/internal/common/bare/doc.go
diff --git a/forged/internal/bare/errors.go b/forged/internal/common/bare/errors.go
index 39c951a..39c951a 100644
--- a/forged/internal/bare/errors.go
+++ b/forged/internal/common/bare/errors.go
diff --git a/forged/internal/bare/limit.go b/forged/internal/common/bare/limit.go
index 212bc05..212bc05 100644
--- a/forged/internal/bare/limit.go
+++ b/forged/internal/common/bare/limit.go
diff --git a/forged/internal/bare/marshal.go b/forged/internal/common/bare/marshal.go
index 1ce942d..1ce942d 100644
--- a/forged/internal/bare/marshal.go
+++ b/forged/internal/common/bare/marshal.go
diff --git a/forged/internal/bare/reader.go b/forged/internal/common/bare/reader.go
index 58325e3..028a7aa 100644
--- a/forged/internal/bare/reader.go
+++ b/forged/internal/common/bare/reader.go
@@ -10,7 +10,7 @@ import (
"math"
"unicode/utf8"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
type byteReader interface {
diff --git a/forged/internal/bare/unions.go b/forged/internal/common/bare/unions.go
index 0270a5f..1020fa0 100644
--- a/forged/internal/bare/unions.go
+++ b/forged/internal/common/bare/unions.go
@@ -21,8 +21,10 @@ type UnionTags struct {
types map[uint64]reflect.Type
}
-var unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
-var unionRegistry map[reflect.Type]*UnionTags
+var (
+ unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
+ unionRegistry map[reflect.Type]*UnionTags
+)
func init() {
unionRegistry = make(map[reflect.Type]*UnionTags)
diff --git a/forged/internal/bare/unmarshal.go b/forged/internal/common/bare/unmarshal.go
index d55f32c..d55f32c 100644
--- a/forged/internal/bare/unmarshal.go
+++ b/forged/internal/common/bare/unmarshal.go
diff --git a/forged/internal/bare/varint.go b/forged/internal/common/bare/varint.go
index a185ac8..a185ac8 100644
--- a/forged/internal/bare/varint.go
+++ b/forged/internal/common/bare/varint.go
diff --git a/forged/internal/bare/writer.go b/forged/internal/common/bare/writer.go
index bada045..80cd7e2 100644
--- a/forged/internal/bare/writer.go
+++ b/forged/internal/common/bare/writer.go
@@ -9,7 +9,7 @@ import (
"io"
"math"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
// A Writer for BARE primitive types.
diff --git a/forged/internal/common/cmap/LICENSE b/forged/internal/common/cmap/LICENSE
new file mode 100644
index 0000000..d5dfee8
--- /dev/null
+++ b/forged/internal/common/cmap/LICENSE
@@ -0,0 +1,22 @@
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY
+EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/forged/internal/cmap/comparable_map.go b/forged/internal/common/cmap/comparable_map.go
index cd9d4ce..e89175c 100644
--- a/forged/internal/cmap/comparable_map.go
+++ b/forged/internal/common/cmap/comparable_map.go
@@ -3,7 +3,7 @@
// Copyright 2024 Runxi Yu (porting it to generics)
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE.BSD file.
+// license that can be found in the LICENSE file.
package cmap
diff --git a/forged/internal/cmap/map.go b/forged/internal/common/cmap/map.go
index 4f43627..7a1fe5b 100644
--- a/forged/internal/cmap/map.go
+++ b/forged/internal/common/cmap/map.go
@@ -3,7 +3,7 @@
// Copyright 2024 Runxi Yu (porting it to generics)
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE.BSD file.
+// license that can be found in the LICENSE file.
// Package cmap provides a generic Map safe for concurrent use.
package cmap
diff --git a/forged/internal/humanize/bytes.go b/forged/internal/common/humanize/bytes.go
index bea504c..bea504c 100644
--- a/forged/internal/humanize/bytes.go
+++ b/forged/internal/common/humanize/bytes.go
diff --git a/forged/internal/misc/back.go b/forged/internal/common/misc/back.go
index 5351359..5351359 100644
--- a/forged/internal/misc/back.go
+++ b/forged/internal/common/misc/back.go
diff --git a/forged/internal/misc/iter.go b/forged/internal/common/misc/iter.go
index 61a96f4..61a96f4 100644
--- a/forged/internal/misc/iter.go
+++ b/forged/internal/common/misc/iter.go
diff --git a/forged/internal/unsorted/version.go b/forged/internal/common/misc/misc.go
index 52c0f32..e9e10ab 100644
--- a/forged/internal/unsorted/version.go
+++ b/forged/internal/common/misc/misc.go
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-package unsorted
-
-var version = "unknown"
+// Package misc provides miscellaneous functions and other definitions.
+package misc
diff --git a/forged/internal/common/misc/net.go b/forged/internal/common/misc/net.go
new file mode 100644
index 0000000..967ea77
--- /dev/null
+++ b/forged/internal/common/misc/net.go
@@ -0,0 +1,42 @@
+package misc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "syscall"
+)
+
+func ListenUnixSocket(ctx context.Context, path string) (listener net.Listener, replaced bool, err error) {
+ listenConfig := net.ListenConfig{} //exhaustruct:ignore
+ listener, err = listenConfig.Listen(ctx, "unix", path)
+ if errors.Is(err, syscall.EADDRINUSE) {
+ replaced = true
+ unlinkErr := syscall.Unlink(path)
+ if unlinkErr != nil {
+ return listener, false, fmt.Errorf("remove existing socket %q: %w", path, unlinkErr)
+ }
+ listener, err = listenConfig.Listen(ctx, "unix", path)
+ }
+ if err != nil {
+ return listener, replaced, fmt.Errorf("listen on unix socket %q: %w", path, err)
+ }
+ return listener, replaced, nil
+}
+
+func Listen(ctx context.Context, net_, addr string) (listener net.Listener, err error) {
+ if net_ == "unix" {
+ listener, _, err = ListenUnixSocket(ctx, addr)
+ if err != nil {
+ return listener, fmt.Errorf("listen unix socket for web: %w", err)
+ }
+ } else {
+ listenConfig := net.ListenConfig{} //exhaustruct:ignore
+ listener, err = listenConfig.Listen(ctx, net_, addr)
+ if err != nil {
+ return listener, fmt.Errorf("listen %s for web: %w", net_, err)
+ }
+ }
+ return listener, nil
+}
diff --git a/forged/internal/misc/misc.go b/forged/internal/common/misc/slices.go
index 398020a..3ad0211 100644
--- a/forged/internal/misc/misc.go
+++ b/forged/internal/common/misc/slices.go
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-// Package misc provides miscellaneous functions and other definitions.
package misc
import "strings"
diff --git a/forged/internal/misc/trivial.go b/forged/internal/common/misc/trivial.go
index e59c17e..83901e0 100644
--- a/forged/internal/misc/trivial.go
+++ b/forged/internal/common/misc/trivial.go
@@ -28,13 +28,13 @@ func QueryEscape(s string) string {
}
// Dereference dereferences a pointer.
-func Dereference[T any](p *T) T {
+func Dereference[T any](p *T) T { //nolint:ireturn
return *p
}
// DereferenceOrZero dereferences a pointer. If the pointer is nil, the zero
// value of its associated type is returned instead.
-func DereferenceOrZero[T any](p *T) T {
+func DereferenceOrZero[T any](p *T) T { //nolint:ireturn
if p != nil {
return *p
}
diff --git a/forged/internal/misc/unsafe.go b/forged/internal/common/misc/unsafe.go
index 6c2192f..d827e7f 100644
--- a/forged/internal/misc/unsafe.go
+++ b/forged/internal/common/misc/unsafe.go
@@ -9,12 +9,12 @@ import "unsafe"
// Memory is borrowed from the string.
// The resulting byte slice must not be modified in any form.
func StringToBytes(s string) (bytes []byte) {
- return unsafe.Slice(unsafe.StringData(s), len(s))
+ return unsafe.Slice(unsafe.StringData(s), len(s)) //#nosec G103
}
// BytesToString converts a byte slice to a string without copying the bytes.
// Memory is borrowed from the byte slice.
// The source byte slice must not be modified.
func BytesToString(b []byte) string {
- return unsafe.String(unsafe.SliceData(b), len(b))
+ return unsafe.String(unsafe.SliceData(b), len(b)) //#nosec G103
}
diff --git a/forged/internal/misc/url.go b/forged/internal/common/misc/url.go
index 346ff76..346ff76 100644
--- a/forged/internal/misc/url.go
+++ b/forged/internal/common/misc/url.go
diff --git a/forged/internal/scfg/.golangci.yaml b/forged/internal/common/scfg/.golangci.yaml
index 59f1970..59f1970 100644
--- a/forged/internal/scfg/.golangci.yaml
+++ b/forged/internal/common/scfg/.golangci.yaml
diff --git a/forged/internal/common/scfg/LICENSE b/forged/internal/common/scfg/LICENSE
new file mode 100644
index 0000000..3649823
--- /dev/null
+++ b/forged/internal/common/scfg/LICENSE
@@ -0,0 +1,18 @@
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/forged/internal/scfg/reader.go b/forged/internal/common/scfg/reader.go
index 6a2bedc..6a2bedc 100644
--- a/forged/internal/scfg/reader.go
+++ b/forged/internal/common/scfg/reader.go
diff --git a/forged/internal/scfg/scfg.go b/forged/internal/common/scfg/scfg.go
index 4533e63..4533e63 100644
--- a/forged/internal/scfg/scfg.go
+++ b/forged/internal/common/scfg/scfg.go
diff --git a/forged/internal/scfg/struct.go b/forged/internal/common/scfg/struct.go
index 98ec943..98ec943 100644
--- a/forged/internal/scfg/struct.go
+++ b/forged/internal/common/scfg/struct.go
diff --git a/forged/internal/scfg/unmarshal.go b/forged/internal/common/scfg/unmarshal.go
index 8befc10..8befc10 100644
--- a/forged/internal/scfg/unmarshal.go
+++ b/forged/internal/common/scfg/unmarshal.go
diff --git a/forged/internal/scfg/writer.go b/forged/internal/common/scfg/writer.go
index 02a07fe..02a07fe 100644
--- a/forged/internal/scfg/writer.go
+++ b/forged/internal/common/scfg/writer.go
diff --git a/forged/internal/config/config.go b/forged/internal/config/config.go
new file mode 100644
index 0000000..da28e05
--- /dev/null
+++ b/forged/internal/config/config.go
@@ -0,0 +1,61 @@
+package config
+
+import (
+ "bufio"
+ "fmt"
+ "log/slog"
+ "os"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/scfg"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/hooks"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/lmtp"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/ssh"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web"
+ "go.lindenii.runxiyu.org/forge/forged/internal/ipc/irc"
+)
+
+type Config struct {
+ DB database.Config `scfg:"db"`
+ Web web.Config `scfg:"web"`
+ Hooks hooks.Config `scfg:"hooks"`
+ LMTP lmtp.Config `scfg:"lmtp"`
+ SSH ssh.Config `scfg:"ssh"`
+ IRC irc.Config `scfg:"irc"`
+ Git struct {
+ RepoDir string `scfg:"repo_dir"`
+ Socket string `scfg:"socket"`
+ } `scfg:"git"`
+ General struct {
+ Title string `scfg:"title"`
+ } `scfg:"general"`
+ Pprof struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ } `scfg:"pprof"`
+}
+
+func Open(path string) (config Config, err error) {
+ var configFile *os.File
+
+ configFile, err = os.Open(path) //#nosec G304
+ if err != nil {
+ err = fmt.Errorf("open config file: %w", err)
+ return config, err
+ }
+ defer func() {
+ _ = configFile.Close()
+ }()
+
+ decoder := scfg.NewDecoder(bufio.NewReader(configFile))
+ err = decoder.Decode(&config)
+ if err != nil {
+ err = fmt.Errorf("decode config file: %w", err)
+ return config, err
+ }
+ for _, u := range decoder.UnknownDirectives() {
+ slog.Warn("unknown configuration directive", "directive", u)
+ }
+
+ return config, err
+}
diff --git a/forged/internal/database/config.go b/forged/internal/database/config.go
new file mode 100644
index 0000000..3697693
--- /dev/null
+++ b/forged/internal/database/config.go
@@ -0,0 +1,5 @@
+package database
+
+type Config struct {
+ Conn string `scfg:"conn"`
+}
diff --git a/forged/internal/database/database.go b/forged/internal/database/database.go
index b995adc..093ed8f 100644
--- a/forged/internal/database/database.go
+++ b/forged/internal/database/database.go
@@ -6,6 +6,7 @@ package database
import (
"context"
+ "fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -19,7 +20,10 @@ type Database struct {
// Open opens a new database connection pool using the provided connection
// string. It returns a Database instance and an error if any occurs.
// It is run indefinitely in the background.
-func Open(connString string) (Database, error) {
- db, err := pgxpool.New(context.Background(), connString)
+func Open(ctx context.Context, config Config) (Database, error) {
+ db, err := pgxpool.New(ctx, config.Conn)
+ if err != nil {
+ err = fmt.Errorf("create pgxpool: %w", err)
+ }
return Database{db}, err
}
diff --git a/forged/internal/database/queries/.gitignore b/forged/internal/database/queries/.gitignore
new file mode 100644
index 0000000..1307f6d
--- /dev/null
+++ b/forged/internal/database/queries/.gitignore
@@ -0,0 +1 @@
+/*.go
diff --git a/forged/internal/embed/.gitignore b/forged/internal/embed/.gitignore
deleted file mode 100644
index 36bd410..0000000
--- a/forged/internal/embed/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-/hookc/hookc
-/git2d/git2d
-/static
-/templates
-/LICENSE*
-/forged
diff --git a/forged/internal/embed/embed.go b/forged/internal/embed/embed.go
deleted file mode 100644
index f731538..0000000
--- a/forged/internal/embed/embed.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-// Package embed provides embedded filesystems created in build-time.
-package embed
-
-import "embed"
-
-// Source contains the licenses collected at build time.
-// It is intended to be served to the user.
-//
-//go:embed LICENSE*
-var Source embed.FS
-
-// Resources contains the templates and static files used by the web interface,
-// as well as the git backend daemon and the hookc helper.
-//
-//go:embed forged/templates/* forged/static/*
-//go:embed hookc/hookc git2d/git2d
-var Resources embed.FS
diff --git a/forged/internal/global/global.go b/forged/internal/global/global.go
new file mode 100644
index 0000000..2aa8049
--- /dev/null
+++ b/forged/internal/global/global.go
@@ -0,0 +1,8 @@
+package global
+
+type Global struct {
+ ForgeTitle string
+ ForgeVersion string
+ SSHPubkey string
+ SSHFingerprint string
+}
diff --git a/forged/internal/incoming/hooks/config.go b/forged/internal/incoming/hooks/config.go
new file mode 100644
index 0000000..0d23dc0
--- /dev/null
+++ b/forged/internal/incoming/hooks/config.go
@@ -0,0 +1,6 @@
+package hooks
+
+type Config struct {
+ Socket string `scfg:"socket"`
+ Execs string `scfg:"execs"`
+}
diff --git a/forged/internal/incoming/hooks/hooks.go b/forged/internal/incoming/hooks/hooks.go
new file mode 100644
index 0000000..dfdf172
--- /dev/null
+++ b/forged/internal/incoming/hooks/hooks.go
@@ -0,0 +1,80 @@
+package hooks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/gliderlabs/ssh"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/cmap"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+)
+
+type Server struct {
+ hookMap cmap.Map[string, hookInfo]
+ socketPath string
+ executablesPath string
+ global *global.Global
+}
+type hookInfo struct {
+ session ssh.Session
+ pubkey string
+ directAccess bool
+ repoPath string
+ userID int
+ userType string
+ repoID int
+ groupPath []string
+ repoName string
+ contribReq string
+}
+
+func New(config Config, global *global.Global) (server *Server) {
+ return &Server{
+ socketPath: config.Socket,
+ executablesPath: config.Execs,
+ hookMap: cmap.Map[string, hookInfo]{},
+ global: global,
+ }
+}
+
+func (server *Server) Run(ctx context.Context) error {
+ listener, _, err := misc.ListenUnixSocket(ctx, server.socketPath)
+ if err != nil {
+ return fmt.Errorf("listen unix socket for hooks: %w", err)
+ }
+ defer func() {
+ _ = listener.Close()
+ }()
+
+ stop := context.AfterFunc(ctx, func() {
+ _ = listener.Close()
+ })
+ defer stop()
+
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("accept conn: %w", err)
+ }
+
+ go server.handleConn(ctx, conn)
+ }
+}
+
+func (server *Server) handleConn(ctx context.Context, conn net.Conn) {
+ defer func() {
+ _ = conn.Close()
+ }()
+ unblock := context.AfterFunc(ctx, func() {
+ _ = conn.SetDeadline(time.Now())
+ _ = conn.Close()
+ })
+ defer unblock()
+}
diff --git a/forged/internal/incoming/lmtp/config.go b/forged/internal/incoming/lmtp/config.go
new file mode 100644
index 0000000..6241608
--- /dev/null
+++ b/forged/internal/incoming/lmtp/config.go
@@ -0,0 +1,9 @@
+package lmtp
+
+type Config struct {
+ Socket string `scfg:"socket"`
+ Domain string `scfg:"domain"`
+ MaxSize int64 `scfg:"max_size"`
+ WriteTimeout uint32 `scfg:"write_timeout"`
+ ReadTimeout uint32 `scfg:"read_timeout"`
+}
diff --git a/forged/internal/incoming/lmtp/lmtp.go b/forged/internal/incoming/lmtp/lmtp.go
new file mode 100644
index 0000000..a7782a2
--- /dev/null
+++ b/forged/internal/incoming/lmtp/lmtp.go
@@ -0,0 +1,70 @@
+package lmtp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "time"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+)
+
+type Server struct {
+ socket string
+ domain string
+ maxSize int64
+ writeTimeout uint32
+ readTimeout uint32
+ global *global.Global
+}
+
+func New(config Config, global *global.Global) (server *Server) {
+ return &Server{
+ socket: config.Socket,
+ domain: config.Domain,
+ maxSize: config.MaxSize,
+ writeTimeout: config.WriteTimeout,
+ readTimeout: config.ReadTimeout,
+ global: global,
+ }
+}
+
+func (server *Server) Run(ctx context.Context) error {
+ listener, _, err := misc.ListenUnixSocket(ctx, server.socket)
+ if err != nil {
+ return fmt.Errorf("listen unix socket for LMTP: %w", err)
+ }
+ defer func() {
+ _ = listener.Close()
+ }()
+
+ stop := context.AfterFunc(ctx, func() {
+ _ = listener.Close()
+ })
+ defer stop()
+
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("accept conn: %w", err)
+ }
+
+ go server.handleConn(ctx, conn)
+ }
+}
+
+func (server *Server) handleConn(ctx context.Context, conn net.Conn) {
+ defer func() {
+ _ = conn.Close()
+ }()
+ unblock := context.AfterFunc(ctx, func() {
+ _ = conn.SetDeadline(time.Now())
+ _ = conn.Close()
+ })
+ defer unblock()
+}
diff --git a/forged/internal/incoming/ssh/config.go b/forged/internal/incoming/ssh/config.go
new file mode 100644
index 0000000..7d22cc1
--- /dev/null
+++ b/forged/internal/incoming/ssh/config.go
@@ -0,0 +1,9 @@
+package ssh
+
+type Config struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ Key string `scfg:"key"`
+ Root string `scfg:"root"`
+ ShutdownTimeout uint32 `scfg:"shutdown_timeout"`
+}
diff --git a/forged/internal/incoming/ssh/ssh.go b/forged/internal/incoming/ssh/ssh.go
new file mode 100644
index 0000000..527cd28
--- /dev/null
+++ b/forged/internal/incoming/ssh/ssh.go
@@ -0,0 +1,89 @@
+package ssh
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ gliderssh "github.com/gliderlabs/ssh"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+ gossh "golang.org/x/crypto/ssh"
+)
+
+type Server struct {
+ gliderServer *gliderssh.Server
+ privkey gossh.Signer
+ net string
+ addr string
+ root string
+ shutdownTimeout uint32
+ global *global.Global
+}
+
+func New(config Config, global *global.Global) (server *Server, err error) {
+ server = &Server{
+ net: config.Net,
+ addr: config.Addr,
+ root: config.Root,
+ shutdownTimeout: config.ShutdownTimeout,
+ global: global,
+ } //exhaustruct:ignore
+
+ var privkeyBytes []byte
+
+ privkeyBytes, err = os.ReadFile(config.Key)
+ if err != nil {
+ return server, fmt.Errorf("read SSH private key: %w", err)
+ }
+
+ server.privkey, err = gossh.ParsePrivateKey(privkeyBytes)
+ if err != nil {
+ return server, fmt.Errorf("parse SSH private key: %w", err)
+ }
+
+ server.global.SSHPubkey = misc.BytesToString(gossh.MarshalAuthorizedKey(server.privkey.PublicKey()))
+ server.global.SSHFingerprint = gossh.FingerprintSHA256(server.privkey.PublicKey())
+
+ server.gliderServer = &gliderssh.Server{
+ Handler: handle,
+ PublicKeyHandler: func(ctx gliderssh.Context, key gliderssh.PublicKey) bool { return true },
+ KeyboardInteractiveHandler: func(ctx gliderssh.Context, challenge gossh.KeyboardInteractiveChallenge) bool { return true },
+ } //exhaustruct:ignore
+ server.gliderServer.AddHostKey(server.privkey)
+
+ return server, nil
+}
+
+func (server *Server) Run(ctx context.Context) (err error) {
+ listener, err := misc.Listen(ctx, server.net, server.addr)
+ if err != nil {
+ return fmt.Errorf("listen for SSH: %w", err)
+ }
+ defer func() {
+ _ = listener.Close()
+ }()
+
+ stop := context.AfterFunc(ctx, func() {
+ shCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Duration(server.shutdownTimeout)*time.Second)
+ defer cancel()
+ _ = server.gliderServer.Shutdown(shCtx)
+ _ = listener.Close()
+ })
+ defer stop()
+
+ err = server.gliderServer.Serve(listener)
+ if err != nil {
+ if errors.Is(err, gliderssh.ErrServerClosed) || ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("serve SSH: %w", err)
+ }
+ panic("unreachable")
+}
+
+func handle(session gliderssh.Session) {
+ panic("SSH server handler not implemented yet")
+}
diff --git a/forged/internal/incoming/web/authn.go b/forged/internal/incoming/web/authn.go
new file mode 100644
index 0000000..46263ee
--- /dev/null
+++ b/forged/internal/incoming/web/authn.go
@@ -0,0 +1,33 @@
+package web
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/jackc/pgx/v5"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func userResolver(r *http.Request) (string, string, error) {
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ if errors.Is(err, http.ErrNoCookie) {
+ return "", "", nil
+ }
+ return "", "", err
+ }
+
+ tokenHash := sha256.Sum256([]byte(cookie.Value))
+
+ session, err := types.Base(r).Queries.GetUserFromSession(r.Context(), tokenHash[:])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", "", nil
+ }
+ return "", "", err
+ }
+
+ return fmt.Sprint(session.UserID), session.Username, nil
+}
diff --git a/forged/internal/incoming/web/config.go b/forged/internal/incoming/web/config.go
new file mode 100644
index 0000000..8d32b34
--- /dev/null
+++ b/forged/internal/incoming/web/config.go
@@ -0,0 +1,16 @@
+package web
+
+type Config struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ Root string `scfg:"root"`
+ CookieExpiry int `scfg:"cookie_expiry"`
+ ReadTimeout uint32 `scfg:"read_timeout"`
+ WriteTimeout uint32 `scfg:"write_timeout"`
+ IdleTimeout uint32 `scfg:"idle_timeout"`
+ MaxHeaderBytes int `scfg:"max_header_bytes"`
+ ReverseProxy bool `scfg:"reverse_proxy"`
+ ShutdownTimeout uint32 `scfg:"shutdown_timeout"`
+ TemplatesPath string `scfg:"templates_path"`
+ StaticPath string `scfg:"static_path"`
+}
diff --git a/forged/internal/incoming/web/handler.go b/forged/internal/incoming/web/handler.go
new file mode 100644
index 0000000..20f7e79
--- /dev/null
+++ b/forged/internal/incoming/web/handler.go
@@ -0,0 +1,77 @@
+package web
+
+import (
+ "html/template"
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+ handlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers"
+ repoHandlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers/repo"
+ specialHandlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers/special"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+)
+
+type handler struct {
+ r *Router
+}
+
+func NewHandler(cfg Config, global *global.Global, queries *queries.Queries) *handler {
+ h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy).Global(global).Queries(queries).UserResolver(userResolver)}
+
+ staticFS := http.FileServer(http.Dir(cfg.StaticPath))
+ h.r.ANYHTTP("-/static/*rest",
+ http.StripPrefix("/-/static/", staticFS),
+ WithDirIfEmpty("rest"),
+ )
+
+ funcs := template.FuncMap{
+ "path_escape": misc.PathEscape,
+ "query_escape": misc.QueryEscape,
+ "minus": misc.Minus,
+ "first_line": misc.FirstLine,
+ "dereference_error": misc.DereferenceOrZero[error],
+ }
+ t := templates.MustParseDir(cfg.TemplatesPath, funcs)
+ renderer := templates.New(t)
+
+ indexHTTP := handlers.NewIndexHTTP(renderer)
+ loginHTTP := specialHandlers.NewLoginHTTP(renderer, cfg.CookieExpiry)
+ groupHTTP := handlers.NewGroupHTTP(renderer)
+ repoHTTP := repoHandlers.NewHTTP(renderer)
+ notImpl := handlers.NewNotImplementedHTTP(renderer)
+
+ // Index
+ h.r.GET("/", indexHTTP.Index)
+
+ // Top-level utilities
+ h.r.ANY("-/login", loginHTTP.Login)
+ h.r.ANY("-/users", notImpl.Handle)
+
+ // Group index
+ h.r.GET("@group/", groupHTTP.Index)
+ h.r.POST("@group/", groupHTTP.Post)
+
+ // Repo index
+ h.r.GET("@group/-/repos/:repo/", repoHTTP.Index)
+
+ // Repo (not implemented yet)
+ h.r.ANY("@group/-/repos/:repo/info", notImpl.Handle)
+ h.r.ANY("@group/-/repos/:repo/git-upload-pack", notImpl.Handle)
+
+ // Repo features
+ h.r.GET("@group/-/repos/:repo/branches/", notImpl.Handle)
+ h.r.GET("@group/-/repos/:repo/log/", notImpl.Handle)
+ h.r.GET("@group/-/repos/:repo/commit/:commit", notImpl.Handle)
+ h.r.GET("@group/-/repos/:repo/tree/*rest", repoHTTP.Tree, WithDirIfEmpty("rest"))
+ h.r.GET("@group/-/repos/:repo/raw/*rest", repoHTTP.Raw, WithDirIfEmpty("rest"))
+ h.r.GET("@group/-/repos/:repo/contrib/", notImpl.Handle)
+ h.r.GET("@group/-/repos/:repo/contrib/:mr", notImpl.Handle)
+
+ return h
+}
+
+func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h.r.ServeHTTP(w, r)
+}
diff --git a/forged/internal/incoming/web/handlers/group.go b/forged/internal/incoming/web/handlers/group.go
new file mode 100644
index 0000000..3201491
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/group.go
@@ -0,0 +1,92 @@
+package handlers
+
+import (
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+type GroupHTTP struct {
+ r templates.Renderer
+}
+
+func NewGroupHTTP(r templates.Renderer) *GroupHTTP {
+ return &GroupHTTP{
+ r: r,
+ }
+}
+
+func (h *GroupHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ base := wtypes.Base(r)
+ userID, err := strconv.ParseInt(base.UserID, 10, 64)
+ if err != nil {
+ userID = 0
+ }
+
+ queryParams := queries.GetGroupByPathParams{
+ Column1: base.URLSegments,
+ UserID: userID,
+ }
+ p, err := base.Queries.GetGroupByPath(r.Context(), queryParams)
+ if err != nil {
+ slog.Error("failed to get group ID by path", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ subgroups, err := base.Queries.GetSubgroups(r.Context(), &p.ID)
+ if err != nil {
+ slog.Error("failed to get subgroups", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ // TODO: gracefully fail this part of the page
+ }
+ repos, err := base.Queries.GetReposInGroup(r.Context(), p.ID)
+ if err != nil {
+ slog.Error("failed to get repos in group", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ // TODO: gracefully fail this part of the page
+ }
+ err = h.r.Render(w, "group", struct {
+ BaseData *wtypes.BaseData
+ Subgroups []queries.GetSubgroupsRow
+ Repos []queries.GetReposInGroupRow
+ Description string
+ DirectAccess bool
+ }{
+ BaseData: base,
+ Subgroups: subgroups,
+ Repos: repos,
+ Description: p.Description,
+ DirectAccess: p.HasRole,
+ })
+ if err != nil {
+ slog.Error("failed to render index page", "error", err)
+ }
+}
+
+func (h *GroupHTTP) Post(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ base := wtypes.Base(r)
+ userID, err := strconv.ParseInt(base.UserID, 10, 64)
+ if err != nil {
+ userID = 0
+ }
+
+ queryParams := queries.GetGroupByPathParams{
+ Column1: base.URLSegments,
+ UserID: userID,
+ }
+ p, err := base.Queries.GetGroupByPath(r.Context(), queryParams)
+ if err != nil {
+ slog.Error("failed to get group ID by path", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ if !p.HasRole {
+ http.Error(w, "You do not have the necessary permissions to create repositories in this group.", http.StatusForbidden)
+ return
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/index.go b/forged/internal/incoming/web/handlers/index.go
new file mode 100644
index 0000000..22e6201
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/index.go
@@ -0,0 +1,40 @@
+package handlers
+
+import (
+ "log"
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+type IndexHTTP struct {
+ r templates.Renderer
+}
+
+func NewIndexHTTP(r templates.Renderer) *IndexHTTP {
+ return &IndexHTTP{
+ r: r,
+ }
+}
+
+func (h *IndexHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ groups, err := types.Base(r).Queries.GetRootGroups(r.Context())
+ if err != nil {
+ http.Error(w, "failed to get root groups", http.StatusInternalServerError)
+ log.Println("failed to get root groups", "error", err)
+ return
+ }
+ err = h.r.Render(w, "index", struct {
+ BaseData *types.BaseData
+ Groups []queries.GetRootGroupsRow
+ }{
+ BaseData: types.Base(r),
+ Groups: groups,
+ })
+ if err != nil {
+ log.Println("failed to render index page", "error", err)
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/not_implemented.go b/forged/internal/incoming/web/handlers/not_implemented.go
new file mode 100644
index 0000000..6813c88
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/not_implemented.go
@@ -0,0 +1,22 @@
+package handlers
+
+import (
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+type NotImplementedHTTP struct {
+ r templates.Renderer
+}
+
+func NewNotImplementedHTTP(r templates.Renderer) *NotImplementedHTTP {
+ return &NotImplementedHTTP{
+ r: r,
+ }
+}
+
+func (h *NotImplementedHTTP) Handle(w http.ResponseWriter, _ *http.Request, _ wtypes.Vars) {
+ http.Error(w, "not implemented", http.StatusNotImplemented)
+}
diff --git a/forged/internal/incoming/web/handlers/repo/handler.go b/forged/internal/incoming/web/handlers/repo/handler.go
new file mode 100644
index 0000000..2881d7d
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/handler.go
@@ -0,0 +1,15 @@
+package repo
+
+import (
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+)
+
+type HTTP struct {
+ r templates.Renderer
+}
+
+func NewHTTP(r templates.Renderer) *HTTP {
+ return &HTTP{
+ r: r,
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/repo/index.go b/forged/internal/incoming/web/handlers/repo/index.go
new file mode 100644
index 0000000..1a804b2
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/index.go
@@ -0,0 +1,20 @@
+package repo
+
+import (
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func (h *HTTP) Index(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repo := v["repo"]
+ _ = h.r.Render(w, "repo/index.html", struct {
+ Group string
+ Repo string
+ }{
+ Group: "/" + strings.Join(base.GroupPath, "/") + "/",
+ Repo: repo,
+ })
+}
diff --git a/forged/internal/incoming/web/handlers/repo/raw.go b/forged/internal/incoming/web/handlers/repo/raw.go
new file mode 100644
index 0000000..e421f45
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/raw.go
@@ -0,0 +1,19 @@
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func (h *HTTP) Raw(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repo := v["repo"]
+ rest := v["rest"]
+ if base.DirMode && rest != "" && !strings.HasSuffix(rest, "/") {
+ rest += "/"
+ }
+ _, _ = w.Write([]byte(fmt.Sprintf("raw: repo=%q path=%q", repo, rest)))
+}
diff --git a/forged/internal/incoming/web/handlers/repo/tree.go b/forged/internal/incoming/web/handlers/repo/tree.go
new file mode 100644
index 0000000..3432244
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/tree.go
@@ -0,0 +1,19 @@
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func (h *HTTP) Tree(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repo := v["repo"]
+ rest := v["rest"] // may be ""
+ if base.DirMode && rest != "" && !strings.HasSuffix(rest, "/") {
+ rest += "/"
+ }
+ _, _ = w.Write([]byte(fmt.Sprintf("tree: repo=%q path=%q", repo, rest)))
+}
diff --git a/forged/internal/incoming/web/handlers/special/login.go b/forged/internal/incoming/web/handlers/special/login.go
new file mode 100644
index 0000000..0287c47
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/special/login.go
@@ -0,0 +1,115 @@
+package handlers
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/argon2id"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+type LoginHTTP struct {
+ r templates.Renderer
+ cookieExpiry int
+}
+
+func NewLoginHTTP(r templates.Renderer, cookieExpiry int) *LoginHTTP {
+ return &LoginHTTP{
+ r: r,
+ cookieExpiry: cookieExpiry,
+ }
+}
+
+func (h *LoginHTTP) Login(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ renderLoginPage := func(loginError string) bool {
+ err := h.r.Render(w, "login", struct {
+ BaseData *types.BaseData
+ LoginError string
+ }{
+ BaseData: types.Base(r),
+ LoginError: loginError,
+ })
+ if err != nil {
+ log.Println("failed to render login page", "error", err)
+ http.Error(w, "Failed to render login page", http.StatusInternalServerError)
+ return true
+ }
+ return false
+ }
+
+ if r.Method == http.MethodGet {
+ renderLoginPage("")
+ return
+ }
+
+ username := r.PostFormValue("username")
+ password := r.PostFormValue("password")
+
+ userCreds, err := types.Base(r).Queries.GetUserCreds(r.Context(), &username)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ renderLoginPage("User not found")
+ return
+ }
+ log.Println("failed to get user credentials", "error", err)
+ http.Error(w, "Failed to get user credentials", http.StatusInternalServerError)
+ return
+ }
+
+ if userCreds.PasswordHash == "" {
+ renderLoginPage("No password set for this user")
+ return
+ }
+
+ passwordMatches, err := argon2id.ComparePasswordAndHash(password, userCreds.PasswordHash)
+ if err != nil {
+ log.Println("failed to compare password and hash", "error", err)
+ http.Error(w, "Failed to verify password", http.StatusInternalServerError)
+ return
+ }
+
+ if !passwordMatches {
+ renderLoginPage("Invalid password")
+ return
+ }
+
+ cookieValue := rand.Text()
+
+ now := time.Now()
+ expiry := now.Add(time.Duration(h.cookieExpiry) * time.Second)
+
+ cookie := &http.Cookie{
+ Name: "session",
+ Value: cookieValue,
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: false, // TODO
+ Expires: expiry,
+ Path: "/",
+ } //exhaustruct:ignore
+
+ http.SetCookie(w, cookie)
+
+ tokenHash := sha256.Sum256(misc.StringToBytes(cookieValue))
+
+ err = types.Base(r).Queries.InsertSession(r.Context(), queries.InsertSessionParams{
+ UserID: userCreds.ID,
+ TokenHash: tokenHash[:],
+ ExpiresAt: pgtype.Timestamptz{
+ Time: expiry,
+ Valid: true,
+ },
+ })
+
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
diff --git a/forged/internal/incoming/web/router.go b/forged/internal/incoming/web/router.go
new file mode 100644
index 0000000..8356191
--- /dev/null
+++ b/forged/internal/incoming/web/router.go
@@ -0,0 +1,428 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+type UserResolver func(*http.Request) (id string, username string, err error)
+
+type ErrorRenderers struct {
+ BadRequest func(http.ResponseWriter, *wtypes.BaseData, string)
+ BadRequestColon func(http.ResponseWriter, *wtypes.BaseData)
+ NotFound func(http.ResponseWriter, *wtypes.BaseData)
+ ServerError func(http.ResponseWriter, *wtypes.BaseData, string)
+}
+
+type dirPolicy int
+
+const (
+ dirIgnore dirPolicy = iota
+ dirRequire
+ dirForbid
+ dirRequireIfEmpty
+)
+
+type patKind uint8
+
+const (
+ lit patKind = iota
+ param
+ splat
+ group // @group, must be first token
+)
+
+type patSeg struct {
+ kind patKind
+ lit string
+ key string
+}
+
+type route struct {
+ method string
+ rawPattern string
+ wantDir dirPolicy
+ ifEmptyKey string
+ segs []patSeg
+ h wtypes.HandlerFunc
+ hh http.Handler
+ priority int
+}
+
+type Router struct {
+ routes []route
+ errors ErrorRenderers
+ user UserResolver
+ global *global.Global
+ reverseProxy bool
+ queries *queries.Queries
+}
+
+func NewRouter() *Router { return &Router{} }
+
+func (r *Router) Global(g *global.Global) *Router {
+ r.global = g
+ return r
+}
+func (r *Router) Queries(q *queries.Queries) *Router {
+ r.queries = q
+ return r
+}
+func (r *Router) ReverseProxy(enabled bool) *Router { r.reverseProxy = enabled; return r }
+func (r *Router) Errors(e ErrorRenderers) *Router { r.errors = e; return r }
+func (r *Router) UserResolver(u UserResolver) *Router { r.user = u; return r }
+
+type RouteOption func(*route)
+
+func WithDir() RouteOption { return func(rt *route) { rt.wantDir = dirRequire } }
+func WithoutDir() RouteOption { return func(rt *route) { rt.wantDir = dirForbid } }
+func WithDirIfEmpty(param string) RouteOption {
+ return func(rt *route) { rt.wantDir = dirRequireIfEmpty; rt.ifEmptyKey = param }
+}
+
+func (r *Router) GET(pattern string, f wtypes.HandlerFunc, opts ...RouteOption) {
+ r.handle("GET", pattern, f, nil, opts...)
+}
+
+func (r *Router) POST(pattern string, f wtypes.HandlerFunc, opts ...RouteOption) {
+ r.handle("POST", pattern, f, nil, opts...)
+}
+
+func (r *Router) ANY(pattern string, f wtypes.HandlerFunc, opts ...RouteOption) {
+ r.handle("", pattern, f, nil, opts...)
+}
+
+func (r *Router) ANYHTTP(pattern string, hh http.Handler, opts ...RouteOption) {
+ r.handle("", pattern, nil, hh, opts...)
+}
+
+func (r *Router) handle(method, pattern string, f wtypes.HandlerFunc, hh http.Handler, opts ...RouteOption) {
+ want := dirIgnore
+ if strings.HasSuffix(pattern, "/") {
+ want = dirRequire
+ pattern = strings.TrimSuffix(pattern, "/")
+ } else if pattern != "" {
+ want = dirForbid
+ }
+ segs, prio := compilePattern(pattern)
+ rt := route{
+ method: method,
+ rawPattern: pattern,
+ wantDir: want,
+ segs: segs,
+ h: f,
+ hh: hh,
+ priority: prio,
+ }
+ for _, o := range opts {
+ o(&rt)
+ }
+ r.routes = append(r.routes, rt)
+
+ sort.SliceStable(r.routes, func(i, j int) bool {
+ return r.routes[i].priority > r.routes[j].priority
+ })
+}
+
+func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ segments, dirMode, err := splitAndUnescapePath(req.URL.EscapedPath())
+ if err != nil {
+ r.err400(w, &wtypes.BaseData{Global: r.global}, "Error parsing request URI: "+err.Error())
+ return
+ }
+ for _, s := range segments {
+ if strings.Contains(s, ":") {
+ r.err400Colon(w, &wtypes.BaseData{Global: r.global})
+ return
+ }
+ }
+
+ bd := &wtypes.BaseData{
+ Global: r.global,
+ URLSegments: segments,
+ DirMode: dirMode,
+ Queries: r.queries,
+ }
+ req = req.WithContext(wtypes.WithBaseData(req.Context(), bd))
+
+ bd.RefType, bd.RefName, err = GetParamRefTypeName(req)
+ if err != nil {
+ r.err400(w, bd, "Error parsing ref query parameters: "+err.Error())
+ return
+ }
+
+ if r.user != nil {
+ uid, uname, uerr := r.user(req)
+ if uerr != nil {
+ r.err500(w, bd, "Error getting user info from request: "+uerr.Error())
+ return
+ }
+ bd.UserID = uid
+ bd.Username = uname
+ }
+
+ method := req.Method
+ var pathMatched bool
+ var matchedRaw string
+
+ for _, rt := range r.routes {
+ ok, vars, sepIdx := match(rt.segs, segments)
+ if !ok {
+ continue
+ }
+ pathMatched = true
+ matchedRaw = rt.rawPattern
+
+ switch rt.wantDir {
+ case dirRequire:
+ if !dirMode && redirectAddSlash(w, req) {
+ return
+ }
+ case dirForbid:
+ if dirMode && redirectDropSlash(w, req) {
+ return
+ }
+ case dirRequireIfEmpty:
+ if v := vars[rt.ifEmptyKey]; v == "" && !dirMode && redirectAddSlash(w, req) {
+ return
+ }
+ }
+
+ bd.SeparatorIndex = sepIdx
+ if g := vars["group"]; g == "" {
+ bd.GroupPath = []string{}
+ } else {
+ bd.GroupPath = strings.Split(g, "/")
+ }
+
+ if rt.method != "" && !(rt.method == method || (method == http.MethodHead && rt.method == http.MethodGet)) {
+ continue
+ }
+
+ if rt.h != nil {
+ rt.h(w, req, wtypes.Vars(vars))
+ } else if rt.hh != nil {
+ rt.hh.ServeHTTP(w, req)
+ } else {
+ r.err500(w, bd, "route has no handler")
+ }
+ return
+ }
+
+ if pathMatched {
+ w.Header().Set("Allow", allowForPattern(r.routes, matchedRaw))
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ r.err404(w, bd)
+}
+
+func compilePattern(pat string) ([]patSeg, int) {
+ if pat == "" || pat == "/" {
+ return nil, 1000
+ }
+ pat = strings.Trim(pat, "/")
+ raw := strings.Split(pat, "/")
+
+ segs := make([]patSeg, 0, len(raw))
+ prio := 0
+ for i, t := range raw {
+ switch {
+ case t == "@group":
+ if i != 0 {
+ segs = append(segs, patSeg{kind: lit, lit: t})
+ prio += 10
+ continue
+ }
+ segs = append(segs, patSeg{kind: group})
+ prio += 1
+ case strings.HasPrefix(t, ":"):
+ segs = append(segs, patSeg{kind: param, key: t[1:]})
+ prio += 5
+ case strings.HasPrefix(t, "*"):
+ segs = append(segs, patSeg{kind: splat, key: t[1:]})
+ default:
+ segs = append(segs, patSeg{kind: lit, lit: t})
+ prio += 10
+ }
+ }
+ return segs, prio
+}
+
+func match(pat []patSeg, segs []string) (bool, map[string]string, int) {
+ vars := make(map[string]string)
+ i := 0
+ sepIdx := -1
+ for pi := 0; pi < len(pat); pi++ {
+ ps := pat[pi]
+ switch ps.kind {
+ case group:
+ start := i
+ for i < len(segs) && segs[i] != "-" {
+ i++
+ }
+ if start < i {
+ vars["group"] = strings.Join(segs[start:i], "/")
+ } else {
+ vars["group"] = ""
+ }
+ if i < len(segs) && segs[i] == "-" {
+ sepIdx = i
+ }
+ case lit:
+ if i >= len(segs) || segs[i] != ps.lit {
+ return false, nil, -1
+ }
+ i++
+ case param:
+ if i >= len(segs) {
+ return false, nil, -1
+ }
+ vars[ps.key] = segs[i]
+ i++
+ case splat:
+ if i < len(segs) {
+ vars[ps.key] = strings.Join(segs[i:], "/")
+ i = len(segs)
+ } else {
+ vars[ps.key] = ""
+ }
+ pi = len(pat)
+ }
+ }
+ if i != len(segs) {
+ return false, nil, -1
+ }
+ return true, vars, sepIdx
+}
+
+func splitAndUnescapePath(escaped string) ([]string, bool, error) {
+ if escaped == "" {
+ return nil, false, nil
+ }
+ dir := strings.HasSuffix(escaped, "/")
+ path := strings.Trim(escaped, "/")
+ if path == "" {
+ return []string{}, dir, nil
+ }
+ raw := strings.Split(path, "/")
+ out := make([]string, 0, len(raw))
+ for _, seg := range raw {
+ u, err := url.PathUnescape(seg)
+ if err != nil {
+ return nil, dir, err
+ }
+ if u != "" {
+ out = append(out, u)
+ }
+ }
+ return out, dir, nil
+}
+
+func redirectAddSlash(w http.ResponseWriter, r *http.Request) bool {
+ u := *r.URL
+ u.Path = u.EscapedPath() + "/"
+ http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
+ return true
+}
+
+func redirectDropSlash(w http.ResponseWriter, r *http.Request) bool {
+ u := *r.URL
+ u.Path = strings.TrimRight(u.EscapedPath(), "/")
+ if u.Path == "" {
+ u.Path = "/"
+ }
+ http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
+ return true
+}
+
+func allowForPattern(routes []route, raw string) string {
+ seen := map[string]struct{}{}
+ out := make([]string, 0, 4)
+ for _, rt := range routes {
+ if rt.rawPattern != raw || rt.method == "" {
+ continue
+ }
+ if _, ok := seen[rt.method]; ok {
+ continue
+ }
+ seen[rt.method] = struct{}{}
+ out = append(out, rt.method)
+ }
+ sort.Strings(out)
+ return strings.Join(out, ", ")
+}
+
+func (r *Router) err400(w http.ResponseWriter, b *wtypes.BaseData, msg string) {
+ if r.errors.BadRequest != nil {
+ r.errors.BadRequest(w, b, msg)
+ return
+ }
+ http.Error(w, msg, http.StatusBadRequest)
+}
+
+func (r *Router) err400Colon(w http.ResponseWriter, b *wtypes.BaseData) {
+ if r.errors.BadRequestColon != nil {
+ r.errors.BadRequestColon(w, b)
+ return
+ }
+ http.Error(w, "bad request", http.StatusBadRequest)
+}
+
+func (r *Router) err404(w http.ResponseWriter, b *wtypes.BaseData) {
+ if r.errors.NotFound != nil {
+ r.errors.NotFound(w, b)
+ return
+ }
+ http.NotFound(w, nil)
+}
+
+func (r *Router) err500(w http.ResponseWriter, b *wtypes.BaseData, msg string) {
+ if r.errors.ServerError != nil {
+ r.errors.ServerError(w, b, msg)
+ return
+ }
+ http.Error(w, msg, http.StatusInternalServerError)
+}
+
+func GetParamRefTypeName(request *http.Request) (retRefType, retRefName string, err error) {
+ rawQuery := request.URL.RawQuery
+ queryValues, err := url.ParseQuery(rawQuery)
+ if err != nil {
+ return
+ }
+ done := false
+ for _, refType := range []string{"commit", "branch", "tag"} {
+ refName, ok := queryValues[refType]
+ if ok {
+ if done {
+ err = errDupRefSpec
+ return
+ }
+ done = true
+ if len(refName) != 1 {
+ err = errDupRefSpec
+ return
+ }
+ retRefName = refName[0]
+ retRefType = refType
+ }
+ }
+ if !done {
+ retRefType = ""
+ retRefName = ""
+ err = nil
+ }
+ return
+}
+
+var (
+ errDupRefSpec = fmt.Errorf("duplicate ref specifications")
+)
diff --git a/forged/internal/incoming/web/server.go b/forged/internal/incoming/web/server.go
new file mode 100644
index 0000000..465657c
--- /dev/null
+++ b/forged/internal/incoming/web/server.go
@@ -0,0 +1,70 @@
+package web
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+)
+
+type Server struct {
+ net string
+ addr string
+ root string
+ httpServer *http.Server
+ shutdownTimeout uint32
+ global *global.Global
+}
+
+func New(config Config, global *global.Global, queries *queries.Queries) *Server {
+ httpServer := &http.Server{
+ Handler: NewHandler(config, global, queries),
+ ReadTimeout: time.Duration(config.ReadTimeout) * time.Second,
+ WriteTimeout: time.Duration(config.WriteTimeout) * time.Second,
+ IdleTimeout: time.Duration(config.IdleTimeout) * time.Second,
+ MaxHeaderBytes: config.MaxHeaderBytes,
+ } //exhaustruct:ignore
+ return &Server{
+ net: config.Net,
+ addr: config.Addr,
+ root: config.Root,
+ shutdownTimeout: config.ShutdownTimeout,
+ httpServer: httpServer,
+ global: global,
+ }
+}
+
+func (server *Server) Run(ctx context.Context) (err error) {
+ server.httpServer.BaseContext = func(_ net.Listener) context.Context { return ctx }
+
+ listener, err := misc.Listen(ctx, server.net, server.addr)
+ if err != nil {
+ return fmt.Errorf("listen for web: %w", err)
+ }
+ defer func() {
+ _ = listener.Close()
+ }()
+
+ stop := context.AfterFunc(ctx, func() {
+ shCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Duration(server.shutdownTimeout)*time.Second)
+ defer cancel()
+ _ = server.httpServer.Shutdown(shCtx)
+ _ = listener.Close()
+ })
+ defer stop()
+
+ err = server.httpServer.Serve(listener)
+ if err != nil {
+ if errors.Is(err, http.ErrServerClosed) || ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("serve web: %w", err)
+ }
+ panic("unreachable")
+}
diff --git a/forged/internal/incoming/web/templates/load.go b/forged/internal/incoming/web/templates/load.go
new file mode 100644
index 0000000..4a6fc49
--- /dev/null
+++ b/forged/internal/incoming/web/templates/load.go
@@ -0,0 +1,31 @@
+package templates
+
+import (
+ "html/template"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+func MustParseDir(dir string, funcs template.FuncMap) *template.Template {
+ base := template.New("").Funcs(funcs)
+
+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ _, err = base.Parse(string(b))
+ return err
+ })
+ if err != nil {
+ panic(err)
+ }
+ return base
+}
diff --git a/forged/internal/incoming/web/templates/renderer.go b/forged/internal/incoming/web/templates/renderer.go
new file mode 100644
index 0000000..1e2f325
--- /dev/null
+++ b/forged/internal/incoming/web/templates/renderer.go
@@ -0,0 +1,23 @@
+package templates
+
+import (
+ "html/template"
+ "net/http"
+)
+
+type Renderer interface {
+ Render(w http.ResponseWriter, name string, data any) error
+}
+
+type tmplRenderer struct {
+ t *template.Template
+}
+
+func New(t *template.Template) Renderer {
+ return &tmplRenderer{t: t}
+}
+
+func (r *tmplRenderer) Render(w http.ResponseWriter, name string, data any) error {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ return r.t.ExecuteTemplate(w, name, data)
+}
diff --git a/forged/internal/incoming/web/types/types.go b/forged/internal/incoming/web/types/types.go
new file mode 100644
index 0000000..bacce24
--- /dev/null
+++ b/forged/internal/incoming/web/types/types.go
@@ -0,0 +1,45 @@
+package types
+
+import (
+ "context"
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+)
+
+// BaseData is per-request context computed by the router and read by handlers.
+// Keep it small and stable; page-specific data should live in view models.
+type BaseData struct {
+ UserID string
+ Username string
+ URLSegments []string
+ DirMode bool
+ GroupPath []string
+ SeparatorIndex int
+ RefType string
+ RefName string
+ Global *global.Global
+ Queries *queries.Queries
+}
+
+type ctxKey struct{}
+
+// WithBaseData attaches BaseData to a context.
+func WithBaseData(ctx context.Context, b *BaseData) context.Context {
+ return context.WithValue(ctx, ctxKey{}, b)
+}
+
+// Base retrieves BaseData from the request (never nil).
+func Base(r *http.Request) *BaseData {
+ if v, ok := r.Context().Value(ctxKey{}).(*BaseData); ok && v != nil {
+ return v
+ }
+ return &BaseData{}
+}
+
+// Vars are route variables captured by the router (e.g., :repo, *rest).
+type Vars map[string]string
+
+// HandlerFunc is the router↔handler function contract.
+type HandlerFunc func(http.ResponseWriter, *http.Request, Vars)
diff --git a/forged/internal/git2c/client.go b/forged/internal/ipc/git2c/client.go
index ed9390c..8b11035 100644
--- a/forged/internal/git2c/client.go
+++ b/forged/internal/ipc/git2c/client.go
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-// Package git2c provides routines to interact with the git2d backend daemon.
package git2c
import (
+ "context"
"fmt"
"net"
- "go.lindenii.runxiyu.org/forge/forged/internal/bare"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/bare"
)
// Client represents a connection to the git2d backend daemon.
@@ -20,8 +20,9 @@ type Client struct {
}
// NewClient establishes a connection to a git2d socket and returns a new Client.
-func NewClient(socketPath string) (*Client, error) {
- conn, err := net.Dial("unix", socketPath)
+func NewClient(ctx context.Context, socketPath string) (*Client, error) {
+ dialer := &net.Dialer{} //exhaustruct:ignore
+ conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err != nil {
return nil, fmt.Errorf("git2d connection failed: %w", err)
}
@@ -38,9 +39,12 @@ func NewClient(socketPath string) (*Client, error) {
}
// Close terminates the underlying socket connection.
-func (c *Client) Close() error {
+func (c *Client) Close() (err error) {
if c.conn != nil {
- return c.conn.Close()
+ err = c.conn.Close()
+ if err != nil {
+ return fmt.Errorf("close underlying socket: %w", err)
+ }
}
return nil
}
diff --git a/forged/internal/git2c/cmd_index.go b/forged/internal/ipc/git2c/cmd_index.go
index 8862b2c..e9fc435 100644
--- a/forged/internal/git2c/cmd_index.go
+++ b/forged/internal/ipc/git2c/cmd_index.go
@@ -13,10 +13,12 @@ import (
// CmdIndex requests a repository index from git2d and returns the list of commits
// and the contents of a README file if available.
func (c *Client) CmdIndex(repoPath string) ([]Commit, *FilenameContents, error) {
- if err := c.writer.WriteData([]byte(repoPath)); err != nil {
+ err := c.writer.WriteData([]byte(repoPath))
+ if err != nil {
return nil, nil, fmt.Errorf("sending repo path failed: %w", err)
}
- if err := c.writer.WriteUint(1); err != nil {
+ err = c.writer.WriteUint(1)
+ if err != nil {
return nil, nil, fmt.Errorf("sending command failed: %w", err)
}
diff --git a/forged/internal/git2c/cmd_treeraw.go b/forged/internal/ipc/git2c/cmd_treeraw.go
index 492cb84..89b702c 100644
--- a/forged/internal/git2c/cmd_treeraw.go
+++ b/forged/internal/ipc/git2c/cmd_treeraw.go
@@ -12,13 +12,16 @@ import (
// CmdTreeRaw queries git2d for a tree or blob object at the given path within the repository.
// It returns either a directory listing or the contents of a file.
func (c *Client) CmdTreeRaw(repoPath, pathSpec string) ([]TreeEntry, string, error) {
- if err := c.writer.WriteData([]byte(repoPath)); err != nil {
+ err := c.writer.WriteData([]byte(repoPath))
+ if err != nil {
return nil, "", fmt.Errorf("sending repo path failed: %w", err)
}
- if err := c.writer.WriteUint(2); err != nil {
+ err = c.writer.WriteUint(2)
+ if err != nil {
return nil, "", fmt.Errorf("sending command failed: %w", err)
}
- if err := c.writer.WriteData([]byte(pathSpec)); err != nil {
+ err = c.writer.WriteData([]byte(pathSpec))
+ if err != nil {
return nil, "", fmt.Errorf("sending path failed: %w", err)
}
diff --git a/forged/internal/ipc/git2c/doc.go b/forged/internal/ipc/git2c/doc.go
new file mode 100644
index 0000000..e14dae0
--- /dev/null
+++ b/forged/internal/ipc/git2c/doc.go
@@ -0,0 +1,2 @@
+// Package git2c provides routines to interact with the git2d backend daemon.
+package git2c
diff --git a/forged/internal/git2c/git_types.go b/forged/internal/ipc/git2c/git_types.go
index bf13f05..bf13f05 100644
--- a/forged/internal/git2c/git_types.go
+++ b/forged/internal/ipc/git2c/git_types.go
diff --git a/forged/internal/git2c/perror.go b/forged/internal/ipc/git2c/perror.go
index 96bffd5..6bc7595 100644
--- a/forged/internal/git2c/perror.go
+++ b/forged/internal/ipc/git2c/perror.go
@@ -8,7 +8,6 @@ package git2c
import "errors"
var (
- Success error
ErrUnknown = errors.New("git2c: unknown error")
ErrPath = errors.New("git2c: get tree entry by path failed")
ErrRevparse = errors.New("git2c: revparse failed")
@@ -24,7 +23,7 @@ var (
func Perror(errno uint) error {
switch errno {
case 0:
- return Success
+ return nil
case 3:
return ErrPath
case 4:
diff --git a/forged/internal/irc/bot.go b/forged/internal/ipc/irc/bot.go
index 1c6d32f..07008ae 100644
--- a/forged/internal/irc/bot.go
+++ b/forged/internal/ipc/irc/bot.go
@@ -1,31 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-// Package irc provides basic IRC bot functionality.
package irc
import (
+ "context"
"crypto/tls"
+ "fmt"
"log/slog"
"net"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
-// Config contains IRC connection and identity settings for the bot.
-// This should usually be a part of the primary config struct.
-type Config struct {
- Net string `scfg:"net"`
- Addr string `scfg:"addr"`
- TLS bool `scfg:"tls"`
- SendQ uint `scfg:"sendq"`
- Nick string `scfg:"nick"`
- User string `scfg:"user"`
- Gecos string `scfg:"gecos"`
-}
-
// Bot represents an IRC bot client that handles events and allows for sending messages.
type Bot struct {
+ // TODO: Use each config field instead of embedding Config here.
config *Config
ircSendBuffered chan string
ircSendDirectChan chan misc.ErrorBack[string]
@@ -35,24 +25,28 @@ type Bot struct {
func NewBot(c *Config) (b *Bot) {
b = &Bot{
config: c,
- }
+ } //exhaustruct:ignore
return
}
// Connect establishes a new IRC session and starts handling incoming and outgoing messages.
// This method blocks until an error occurs or the connection is closed.
-func (b *Bot) Connect() error {
+func (b *Bot) Connect(ctx context.Context) error {
var err error
var underlyingConn net.Conn
if b.config.TLS {
- underlyingConn, err = tls.Dial(b.config.Net, b.config.Addr, nil)
+ dialer := tls.Dialer{} //exhaustruct:ignore
+ underlyingConn, err = dialer.DialContext(ctx, b.config.Net, b.config.Addr)
} else {
- underlyingConn, err = net.Dial(b.config.Net, b.config.Addr)
+ dialer := net.Dialer{} //exhaustruct:ignore
+ underlyingConn, err = dialer.DialContext(ctx, b.config.Net, b.config.Addr)
}
if err != nil {
- return err
+ return fmt.Errorf("dialing irc: %w", err)
}
- defer underlyingConn.Close()
+ defer func() {
+ _ = underlyingConn.Close()
+ }()
conn := NewConn(underlyingConn)
@@ -165,12 +159,12 @@ func (b *Bot) Send(line string) {
// ConnectLoop continuously attempts to maintain an IRC session.
// If the connection drops, it automatically retries with no delay.
-func (b *Bot) ConnectLoop() {
+func (b *Bot) ConnectLoop(ctx context.Context) {
b.ircSendBuffered = make(chan string, b.config.SendQ)
b.ircSendDirectChan = make(chan misc.ErrorBack[string])
for {
- err := b.Connect()
+ err := b.Connect(ctx)
slog.Error("irc session error", "error", err)
}
}
diff --git a/forged/internal/ipc/irc/config.go b/forged/internal/ipc/irc/config.go
new file mode 100644
index 0000000..b1b5703
--- /dev/null
+++ b/forged/internal/ipc/irc/config.go
@@ -0,0 +1,13 @@
+package irc
+
+// Config contains IRC connection and identity settings for the bot.
+// This should usually be a part of the primary config struct.
+type Config struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ TLS bool `scfg:"tls"`
+ SendQ uint `scfg:"sendq"`
+ Nick string `scfg:"nick"`
+ User string `scfg:"user"`
+ Gecos string `scfg:"gecos"`
+}
diff --git a/forged/internal/irc/conn.go b/forged/internal/ipc/irc/conn.go
index b975b72..b9b208c 100644
--- a/forged/internal/irc/conn.go
+++ b/forged/internal/ipc/irc/conn.go
@@ -2,10 +2,11 @@ package irc
import (
"bufio"
+ "fmt"
"net"
"slices"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
type Conn struct {
@@ -41,9 +42,17 @@ func (c *Conn) ReadMessage() (msg Message, line string, err error) {
}
func (c *Conn) Write(p []byte) (n int, err error) {
- return c.netConn.Write(p)
+ n, err = c.netConn.Write(p)
+ if err != nil {
+ err = fmt.Errorf("write to connection: %w", err)
+ }
+ return n, err
}
func (c *Conn) WriteString(s string) (n int, err error) {
- return c.netConn.Write(misc.StringToBytes(s))
+ n, err = c.netConn.Write(misc.StringToBytes(s))
+ if err != nil {
+ err = fmt.Errorf("write to connection: %w", err)
+ }
+ return n, err
}
diff --git a/forged/internal/ipc/irc/doc.go b/forged/internal/ipc/irc/doc.go
new file mode 100644
index 0000000..dcfca82
--- /dev/null
+++ b/forged/internal/ipc/irc/doc.go
@@ -0,0 +1,2 @@
+// Package irc provides basic IRC bot functionality.
+package irc
diff --git a/forged/internal/irc/errors.go b/forged/internal/ipc/irc/errors.go
index 3506c70..3506c70 100644
--- a/forged/internal/irc/errors.go
+++ b/forged/internal/ipc/irc/errors.go
diff --git a/forged/internal/irc/message.go b/forged/internal/ipc/irc/message.go
index 84b6867..3387bec 100644
--- a/forged/internal/irc/message.go
+++ b/forged/internal/ipc/irc/message.go
@@ -7,7 +7,7 @@ package irc
import (
"bytes"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
type Message struct {
@@ -24,18 +24,18 @@ func Parse(raw []byte) (msg Message, err error) {
if bytes.HasPrefix(sp[0], []byte{'@'}) { // TODO: Check size manually
if len(sp[0]) < 2 {
err = ErrMalformedMsg
- return
+ return msg, err
}
sp[0] = sp[0][1:]
msg.Tags, err = tagsToMap(sp[0])
if err != nil {
- return
+ return msg, err
}
if len(sp) < 2 {
err = ErrMalformedMsg
- return
+ return msg, err
}
sp = sp[1:]
} else {
@@ -45,7 +45,7 @@ func Parse(raw []byte) (msg Message, err error) {
if bytes.HasPrefix(sp[0], []byte{':'}) { // TODO: Check size manually
if len(sp[0]) < 2 {
err = ErrMalformedMsg
- return
+ return msg, err
}
sp[0] = sp[0][1:]
@@ -53,14 +53,14 @@ func Parse(raw []byte) (msg Message, err error) {
if len(sp) < 2 {
err = ErrMalformedMsg
- return
+ return msg, err
}
sp = sp[1:]
}
msg.Command = misc.BytesToString(sp[0])
if len(sp) < 2 {
- return
+ return msg, err
}
sp = sp[1:]
@@ -81,7 +81,7 @@ func Parse(raw []byte) (msg Message, err error) {
msg.Args = append(msg.Args, misc.BytesToString(sp[i]))
}
- return
+ return msg, err
}
var ircv3TagEscapes = map[byte]byte{ //nolint:gochecknoglobals
@@ -97,7 +97,7 @@ func tagsToMap(raw []byte) (tags map[string]string, err error) {
key, value, found := bytes.Cut(rawTag, []byte{'='})
if !found {
err = ErrInvalidIRCv3Tag
- return
+ return tags, err
}
if len(value) == 0 {
tags[misc.BytesToString(key)] = ""
@@ -122,5 +122,5 @@ func tagsToMap(raw []byte) (tags map[string]string, err error) {
}
}
}
- return
+ return tags, err
}
diff --git a/forged/internal/irc/source.go b/forged/internal/ipc/irc/source.go
index d955f45..938751f 100644
--- a/forged/internal/irc/source.go
+++ b/forged/internal/ipc/irc/source.go
@@ -6,13 +6,14 @@ package irc
import (
"bytes"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)
type Source interface {
AsSourceString() string
}
+//nolint:ireturn
func parseSource(s []byte) Source {
nick, userhost, found := bytes.Cut(s, []byte{'!'})
if !found {
diff --git a/forged/internal/misc/deploy.go b/forged/internal/misc/deploy.go
deleted file mode 100644
index 3ee5f92..0000000
--- a/forged/internal/misc/deploy.go
+++ /dev/null
@@ -1,22 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package misc
-
-import (
- "io"
- "io/fs"
- "os"
-)
-
-// DeployBinary copies the contents of a binary file to the target destination path.
-// The destination file is created with executable permissions.
-func DeployBinary(src fs.File, dst string) (err error) {
- var dstFile *os.File
- if dstFile, err = os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755); err != nil {
- return err
- }
- defer dstFile.Close()
- _, err = io.Copy(dstFile, src)
- return err
-}
diff --git a/forged/internal/misc/panic.go b/forged/internal/misc/panic.go
deleted file mode 100644
index 34c49c5..0000000
--- a/forged/internal/misc/panic.go
+++ /dev/null
@@ -1,19 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package misc
-
-// FirstOrPanic returns the value or panics if the error is non-nil.
-func FirstOrPanic[T any](v T, err error) T {
- if err != nil {
- panic(err)
- }
- return v
-}
-
-// NoneOrPanic panics if the provided error is non-nil.
-func NoneOrPanic(err error) {
- if err != nil {
- panic(err)
- }
-}
diff --git a/forged/internal/oldgit/fmtpatch.go b/forged/internal/oldgit/fmtpatch.go
deleted file mode 100644
index 79be5d8..0000000
--- a/forged/internal/oldgit/fmtpatch.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package oldgit
-
-import (
- "bytes"
- "fmt"
- "strings"
- "time"
-
- "github.com/go-git/go-git/v5/plumbing/object"
-)
-
-// FmtCommitPatch formats a commit object as if it was returned by
-// git-format-patch.
-func FmtCommitPatch(commit *object.Commit) (final string, err error) {
- var patch *object.Patch
- var buf bytes.Buffer
- var author object.Signature
- var date string
- var commitTitle, commitDetails string
-
- if _, patch, err = CommitToPatch(commit); err != nil {
- return "", err
- }
-
- author = commit.Author
- date = author.When.Format(time.RFC1123Z)
-
- commitTitle, commitDetails, _ = strings.Cut(commit.Message, "\n")
-
- // This date is hardcoded in Git.
- fmt.Fprintf(&buf, "From %s Mon Sep 17 00:00:00 2001\n", commit.Hash)
- fmt.Fprintf(&buf, "From: %s <%s>\n", author.Name, author.Email)
- fmt.Fprintf(&buf, "Date: %s\n", date)
- fmt.Fprintf(&buf, "Subject: [PATCH] %s\n\n", commitTitle)
-
- if commitDetails != "" {
- commitDetails1, commitDetails2, _ := strings.Cut(commitDetails, "\n")
- if strings.TrimSpace(commitDetails1) == "" {
- commitDetails = commitDetails2
- }
- buf.WriteString(commitDetails)
- buf.WriteString("\n")
- }
- buf.WriteString("---\n")
- fmt.Fprint(&buf, patch.Stats().String())
- fmt.Fprintln(&buf)
-
- buf.WriteString(patch.String())
-
- fmt.Fprintf(&buf, "\n-- \n2.48.1\n")
-
- return buf.String(), nil
-}
diff --git a/forged/internal/oldgit/oldgit.go b/forged/internal/oldgit/oldgit.go
deleted file mode 100644
index 4c99d6a..0000000
--- a/forged/internal/oldgit/oldgit.go
+++ /dev/null
@@ -1,5 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-// Package oldgit provides deprecated functions that depend on go-git.
-package oldgit
diff --git a/forged/internal/oldgit/patch.go b/forged/internal/oldgit/patch.go
deleted file mode 100644
index fc8ef98..0000000
--- a/forged/internal/oldgit/patch.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package oldgit
-
-import (
- "errors"
-
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
-)
-
-// CommitToPatch creates an [object.Patch] from the first parent of a given
-// [object.Commit].
-//
-// TODO: This function should be deprecated as it only diffs with the first
-// parent and does not correctly handle merge commits.
-func CommitToPatch(commit *object.Commit) (parentCommitHash plumbing.Hash, patch *object.Patch, err error) {
- var parentCommit *object.Commit
- var commitTree *object.Tree
-
- parentCommit, err = commit.Parent(0)
- switch {
- case errors.Is(err, object.ErrParentNotFound):
- if commitTree, err = commit.Tree(); err != nil {
- return
- }
- if patch, err = NullTree.Patch(commitTree); err != nil {
- return
- }
- case err != nil:
- return
- default:
- parentCommitHash = parentCommit.Hash
- if patch, err = parentCommit.Patch(commit); err != nil {
- return
- }
- }
- return
-}
-
-// NullTree is a tree object that is empty and has no hash.
-var NullTree object.Tree //nolint:gochecknoglobals
diff --git a/forged/internal/render/chroma.go b/forged/internal/render/chroma.go
deleted file mode 100644
index 64bfde0..0000000
--- a/forged/internal/render/chroma.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package render
-
-import (
- "bytes"
- "html/template"
-
- chromaHTML "github.com/alecthomas/chroma/v2/formatters/html"
- chromaLexers "github.com/alecthomas/chroma/v2/lexers"
- chromaStyles "github.com/alecthomas/chroma/v2/styles"
-)
-
-// Highlight returns HTML with syntax highlighting for the given file content,
-// using Chroma. The lexer is selected based on the filename.
-// If tokenization or formatting fails, a fallback <pre> block is returned with the error.
-func Highlight(filename, content string) template.HTML {
- lexer := chromaLexers.Match(filename)
- if lexer == nil {
- lexer = chromaLexers.Fallback
- }
-
- iterator, err := lexer.Tokenise(nil, content)
- if err != nil {
- return template.HTML("<pre>Error tokenizing file: " + err.Error() + "</pre>") //#nosec G203`
- }
-
- var buf bytes.Buffer
- style := chromaStyles.Get("autumn")
- formatter := chromaHTML.New(
- chromaHTML.WithClasses(true),
- chromaHTML.TabWidth(8),
- )
-
- if err := formatter.Format(&buf, style, iterator); err != nil {
- return template.HTML("<pre>Error formatting file: " + err.Error() + "</pre>") //#nosec G203
- }
-
- return template.HTML(buf.Bytes()) //#nosec G203
-}
diff --git a/forged/internal/render/escape.go b/forged/internal/render/escape.go
deleted file mode 100644
index 031e333..0000000
--- a/forged/internal/render/escape.go
+++ /dev/null
@@ -1,14 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package render
-
-import (
- "html"
- "html/template"
-)
-
-// EscapeHTML just escapes a string and wraps it in [template.HTML].
-func EscapeHTML(s string) template.HTML {
- return template.HTML(html.EscapeString(s)) //#nosec G203
-}
diff --git a/forged/internal/render/readme.go b/forged/internal/render/readme.go
deleted file mode 100644
index fa1be7e..0000000
--- a/forged/internal/render/readme.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package render
-
-import (
- "bytes"
- "html"
- "html/template"
- "strings"
-
- "github.com/microcosm-cc/bluemonday"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/extension"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-var markdownConverter = goldmark.New(goldmark.WithExtensions(extension.GFM)) //nolint:gochecknoglobals
-
-// renderReadme renders and sanitizes README content from a byte slice and filename.
-func Readme(data []byte, filename string) (string, template.HTML) {
- switch strings.ToLower(filename) {
- case "readme":
- return "README", template.HTML("<pre>" + html.EscapeString(misc.BytesToString(data)) + "</pre>") //#nosec G203
- case "readme.md":
- var buf bytes.Buffer
- if err := markdownConverter.Convert(data, &buf); err != nil {
- return "Error fetching README", EscapeHTML("Unable to render README: " + err.Error())
- }
- return "README.md", template.HTML(bluemonday.UGCPolicy().SanitizeBytes(buf.Bytes())) //#nosec G203
- default:
- return filename, template.HTML("<pre>" + html.EscapeString(misc.BytesToString(data)) + "</pre>") //#nosec G203
- }
-}
diff --git a/forged/internal/render/render.go b/forged/internal/render/render.go
deleted file mode 100644
index 465e410..0000000
--- a/forged/internal/render/render.go
+++ /dev/null
@@ -1,5 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-// Package render provides functions to render code and READMEs.
-package render
diff --git a/forged/internal/server/server.go b/forged/internal/server/server.go
new file mode 100644
index 0000000..62a9442
--- /dev/null
+++ b/forged/internal/server/server.go
@@ -0,0 +1,82 @@
+package server
+
+import (
+ "context"
+ "fmt"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/config"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/hooks"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/lmtp"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/ssh"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web"
+ "golang.org/x/sync/errgroup"
+)
+
+type Server struct {
+ config config.Config
+
+ database database.Database
+ hookServer *hooks.Server
+ lmtpServer *lmtp.Server
+ webServer *web.Server
+ sshServer *ssh.Server
+
+ global global.Global
+}
+
+func New(configPath string) (server *Server, err error) {
+ server = &Server{} //exhaustruct:ignore
+
+ server.config, err = config.Open(configPath)
+ if err != nil {
+ return server, fmt.Errorf("open config: %w", err)
+ }
+
+ queries := queries.New(&server.database)
+
+ server.global.ForgeVersion = "unknown" // TODO
+ server.global.ForgeTitle = server.config.General.Title
+
+ server.hookServer = hooks.New(server.config.Hooks, &server.global)
+ server.lmtpServer = lmtp.New(server.config.LMTP, &server.global)
+ server.webServer = web.New(server.config.Web, &server.global, queries)
+ server.sshServer, err = ssh.New(server.config.SSH, &server.global)
+ if err != nil {
+ return server, fmt.Errorf("create SSH server: %w", err)
+ }
+
+ return server, nil
+}
+
+func (server *Server) Run(ctx context.Context) (err error) {
+ // TODO: Not running git2d because it should be run separately.
+ // This needs to be documented somewhere, hence a TODO here for now.
+
+ g, gctx := errgroup.WithContext(ctx)
+
+ server.database, err = database.Open(gctx, server.config.DB)
+ if err != nil {
+ return fmt.Errorf("open database: %w", err)
+ }
+ defer server.database.Close()
+
+ g.Go(func() error { return server.hookServer.Run(gctx) })
+ g.Go(func() error { return server.lmtpServer.Run(gctx) })
+ g.Go(func() error { return server.webServer.Run(gctx) })
+ g.Go(func() error { return server.sshServer.Run(gctx) })
+
+ err = g.Wait()
+ if err != nil {
+ return fmt.Errorf("server error: %w", err)
+ }
+
+ err = ctx.Err()
+ if err != nil {
+ return fmt.Errorf("context exceeded: %w", err)
+ }
+
+ return nil
+}
diff --git a/forged/internal/unsorted/acl.go b/forged/internal/unsorted/acl.go
deleted file mode 100644
index c2e887d..0000000
--- a/forged/internal/unsorted/acl.go
+++ /dev/null
@@ -1,59 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-// getRepoInfo returns the filesystem path and direct access permission for a
-// given repo and a provided ssh public key.
-//
-// TODO: Revamp.
-func (s *Server) getRepoInfo(ctx context.Context, groupPath []string, repoName, sshPubkey string) (repoID int, fsPath string, access bool, contribReq, userType string, userID int, err error) {
- err = s.database.QueryRow(ctx, `
-WITH RECURSIVE group_path_cte AS (
- -- Start: match the first name in the path where parent_group IS NULL
- SELECT
- id,
- parent_group,
- name,
- 1 AS depth
- FROM groups
- WHERE name = ($1::text[])[1]
- AND parent_group IS NULL
-
- UNION ALL
-
- -- Recurse: join next segment of the path
- SELECT
- g.id,
- g.parent_group,
- g.name,
- group_path_cte.depth + 1
- FROM groups g
- JOIN group_path_cte ON g.parent_group = group_path_cte.id
- WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
- AND group_path_cte.depth + 1 <= cardinality($1::text[])
-)
-SELECT
- r.id,
- r.filesystem_path,
- CASE WHEN ugr.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS has_role_in_group,
- r.contrib_requirements,
- COALESCE(u.type, ''),
- COALESCE(u.id, 0)
-FROM group_path_cte g
-JOIN repos r ON r.group_id = g.id
-LEFT JOIN ssh_public_keys s ON s.key_string = $3
-LEFT JOIN users u ON u.id = s.user_id
-LEFT JOIN user_group_roles ugr ON ugr.group_id = g.id AND ugr.user_id = u.id
-WHERE g.depth = cardinality($1::text[])
- AND r.name = $2
-`, pgtype.FlatArray[string](groupPath), repoName, sshPubkey,
- ).Scan(&repoID, &fsPath, &access, &contribReq, &userType, &userID)
- return
-}
diff --git a/forged/internal/unsorted/config.go b/forged/internal/unsorted/config.go
deleted file mode 100644
index 9f07480..0000000
--- a/forged/internal/unsorted/config.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "bufio"
- "errors"
- "log/slog"
- "os"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/database"
- "go.lindenii.runxiyu.org/forge/forged/internal/irc"
- "go.lindenii.runxiyu.org/forge/forged/internal/scfg"
-)
-
-type Config struct {
- HTTP struct {
- Net string `scfg:"net"`
- Addr string `scfg:"addr"`
- CookieExpiry int `scfg:"cookie_expiry"`
- Root string `scfg:"root"`
- ReadTimeout uint32 `scfg:"read_timeout"`
- WriteTimeout uint32 `scfg:"write_timeout"`
- IdleTimeout uint32 `scfg:"idle_timeout"`
- ReverseProxy bool `scfg:"reverse_proxy"`
- } `scfg:"http"`
- Hooks struct {
- Socket string `scfg:"socket"`
- Execs string `scfg:"execs"`
- } `scfg:"hooks"`
- LMTP struct {
- Socket string `scfg:"socket"`
- Domain string `scfg:"domain"`
- MaxSize int64 `scfg:"max_size"`
- WriteTimeout uint32 `scfg:"write_timeout"`
- ReadTimeout uint32 `scfg:"read_timeout"`
- } `scfg:"lmtp"`
- Git struct {
- RepoDir string `scfg:"repo_dir"`
- Socket string `scfg:"socket"`
- DaemonPath string `scfg:"daemon_path"`
- } `scfg:"git"`
- SSH struct {
- Net string `scfg:"net"`
- Addr string `scfg:"addr"`
- Key string `scfg:"key"`
- Root string `scfg:"root"`
- } `scfg:"ssh"`
- IRC irc.Config `scfg:"irc"`
- General struct {
- Title string `scfg:"title"`
- } `scfg:"general"`
- DB struct {
- Type string `scfg:"type"`
- Conn string `scfg:"conn"`
- } `scfg:"db"`
- Pprof struct {
- Net string `scfg:"net"`
- Addr string `scfg:"addr"`
- } `scfg:"pprof"`
-}
-
-// LoadConfig loads a configuration file from the specified path and unmarshals
-// it to the global [config] struct. This may race with concurrent reads from
-// [config]; additional synchronization is necessary if the configuration is to
-// be made reloadable.
-func (s *Server) loadConfig(path string) (err error) {
- var configFile *os.File
- if configFile, err = os.Open(path); err != nil {
- return err
- }
- defer configFile.Close()
-
- decoder := scfg.NewDecoder(bufio.NewReader(configFile))
- if err = decoder.Decode(&s.config); err != nil {
- return err
- }
- for _, u := range decoder.UnknownDirectives() {
- slog.Warn("unknown configuration directive", "directive", u)
- }
-
- if s.config.DB.Type != "postgres" {
- return errors.New("unsupported database type")
- }
-
- if s.database, err = database.Open(s.config.DB.Conn); err != nil {
- return err
- }
-
- s.globalData["forge_title"] = s.config.General.Title
-
- return nil
-}
diff --git a/forged/internal/unsorted/database.go b/forged/internal/unsorted/database.go
deleted file mode 100644
index 222b0c4..0000000
--- a/forged/internal/unsorted/database.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5"
-)
-
-// TODO: All database handling logic in all request handlers must be revamped.
-// We must ensure that each request has all logic in one transaction (subject
-// to exceptions if appropriate) so they get a consistent view of the database
-// at a single point. A failure to do so may cause things as serious as
-// privilege escalation.
-
-// queryNameDesc is a helper function that executes a query and returns a
-// list of nameDesc results. The query must return two string arguments, i.e. a
-// name and a description.
-func (s *Server) queryNameDesc(ctx context.Context, query string, args ...any) (result []nameDesc, err error) {
- var rows pgx.Rows
-
- if rows, err = s.database.Query(ctx, query, args...); err != nil {
- return nil, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var name, description string
- if err = rows.Scan(&name, &description); err != nil {
- return nil, err
- }
- result = append(result, nameDesc{name, description})
- }
- return result, rows.Err()
-}
-
-// nameDesc holds a name and a description.
-type nameDesc struct {
- Name string
- Description string
-}
diff --git a/forged/internal/unsorted/fedauth.go b/forged/internal/unsorted/fedauth.go
deleted file mode 100644
index f54649b..0000000
--- a/forged/internal/unsorted/fedauth.go
+++ /dev/null
@@ -1,97 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "bufio"
- "context"
- "errors"
- "io"
- "net/http"
- "net/url"
- "strings"
-
- "github.com/jackc/pgx/v5"
-)
-
-// fedauth checks whether a user's SSH public key matches the remote username
-// they claim to have on the service. If so, the association is recorded.
-func (s *Server) fedauth(ctx context.Context, userID int, service, remoteUsername, pubkey string) (bool, error) {
- var err error
-
- matched := false
- usernameEscaped := url.PathEscape(remoteUsername)
-
- var req *http.Request
- switch service {
- // TODO: Services should be configurable by the instance administrator
- // and should not be hardcoded in the source code.
- case "sr.ht":
- req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://meta.sr.ht/~"+usernameEscaped+".keys", nil)
- case "github":
- req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://github.com/"+usernameEscaped+".keys", nil)
- case "codeberg":
- req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://codeberg.org/"+usernameEscaped+".keys", nil)
- case "tangled":
- req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://tangled.sh/keys/"+usernameEscaped, nil)
- // TODO: Don't rely on one webview
- default:
- return false, errors.New("unknown federated service")
- }
- if err != nil {
- return false, err
- }
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return false, err
- }
- defer func() {
- _ = resp.Body.Close()
- }()
- buf := bufio.NewReader(resp.Body)
-
- for {
- line, err := buf.ReadString('\n')
- if errors.Is(err, io.EOF) {
- break
- } else if err != nil {
- return false, err
- }
-
- lineSplit := strings.Split(line, " ")
- if len(lineSplit) < 2 {
- continue
- }
- line = strings.Join(lineSplit[:2], " ")
-
- if line == pubkey {
- matched = true
- break
- }
- }
-
- if !matched {
- return false, nil
- }
-
- var txn pgx.Tx
- if txn, err = s.database.Begin(ctx); err != nil {
- return false, err
- }
- defer func() {
- _ = txn.Rollback(ctx)
- }()
- if _, err = txn.Exec(ctx, `UPDATE users SET type = 'federated' WHERE id = $1 AND type = 'pubkey_only'`, userID); err != nil {
- return false, err
- }
- if _, err = txn.Exec(ctx, `INSERT INTO federated_identities (user_id, service, remote_username) VALUES ($1, $2, $3)`, userID, service, remoteUsername); err != nil {
- return false, err
- }
- if err = txn.Commit(ctx); err != nil {
- return false, err
- }
-
- return true, nil
-}
diff --git a/forged/internal/unsorted/git_hooks_handle_linux.go b/forged/internal/unsorted/git_hooks_handle_linux.go
deleted file mode 100644
index f904550..0000000
--- a/forged/internal/unsorted/git_hooks_handle_linux.go
+++ /dev/null
@@ -1,377 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-//
-//go:build linux
-
-package unsorted
-
-import (
- "bytes"
- "context"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "net"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "syscall"
-
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/jackc/pgx/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/ansiec"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-var (
- errGetFD = errors.New("unable to get file descriptor")
- errGetUcred = errors.New("failed getsockopt")
-)
-
-// hooksHandler handles a connection from hookc via the
-// unix socket.
-func (s *Server) hooksHandler(conn net.Conn) {
- var ctx context.Context
- var cancel context.CancelFunc
- var ucred *syscall.Ucred
- var err error
- var cookie []byte
- var packPass packPass
- var sshStderr io.Writer
- var hookRet byte
-
- defer conn.Close()
- ctx, cancel = context.WithCancel(context.Background())
- defer cancel()
-
- // There aren't reasonable cases where someone would run this as
- // another user.
- if ucred, err = getUcred(conn); err != nil {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nUnable to get peer credentials: %v", err)
- return
- }
- uint32uid := uint32(os.Getuid()) //#nosec G115
- if ucred.Uid != uint32uid {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nUID mismatch")
- return
- }
-
- cookie = make([]byte, 64)
- if _, err = conn.Read(cookie); err != nil {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nFailed to read cookie: %v", err)
- return
- }
-
- {
- var ok bool
- packPass, ok = s.packPasses.Load(misc.BytesToString(cookie))
- if !ok {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nInvalid handler cookie")
- return
- }
- }
-
- sshStderr = packPass.session.Stderr()
-
- _, _ = sshStderr.Write([]byte{'\n'})
-
- hookRet = func() byte {
- var argc64 uint64
- if err = binary.Read(conn, binary.NativeEndian, &argc64); err != nil {
- writeRedError(sshStderr, "Failed to read argc: %v", err)
- return 1
- }
- var args []string
- for range argc64 {
- var arg bytes.Buffer
- for {
- nextByte := make([]byte, 1)
- n, err := conn.Read(nextByte)
- if err != nil || n != 1 {
- writeRedError(sshStderr, "Failed to read arg: %v", err)
- return 1
- }
- if nextByte[0] == 0 {
- break
- }
- arg.WriteByte(nextByte[0])
- }
- args = append(args, arg.String())
- }
-
- gitEnv := make(map[string]string)
- for {
- var envLine bytes.Buffer
- for {
- nextByte := make([]byte, 1)
- n, err := conn.Read(nextByte)
- if err != nil || n != 1 {
- writeRedError(sshStderr, "Failed to read environment variable: %v", err)
- return 1
- }
- if nextByte[0] == 0 {
- break
- }
- envLine.WriteByte(nextByte[0])
- }
- if envLine.Len() == 0 {
- break
- }
- kv := envLine.String()
- parts := strings.SplitN(kv, "=", 2)
- if len(parts) < 2 {
- writeRedError(sshStderr, "Invalid environment variable line: %v", kv)
- return 1
- }
- gitEnv[parts[0]] = parts[1]
- }
-
- var stdin bytes.Buffer
- if _, err = io.Copy(&stdin, conn); err != nil {
- writeRedError(conn, "Failed to read to the stdin buffer: %v", err)
- }
-
- switch filepath.Base(args[0]) {
- case "pre-receive":
- if packPass.directAccess {
- return 0
- }
- allOK := true
- for {
- var line, oldOID, rest, newIOID, refName string
- var found bool
- var oldHash, newHash plumbing.Hash
- var oldCommit, newCommit *object.Commit
- var pushOptCount int
-
- pushOptCount, err = strconv.Atoi(gitEnv["GIT_PUSH_OPTION_COUNT"])
- if err != nil {
- writeRedError(sshStderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
- return 1
- }
-
- // TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
- // Also it'd be nice to be able to combine users or whatever
- if packPass.contribReq == "federated" && packPass.userType != "federated" && packPass.userType != "registered" {
- if pushOptCount == 0 {
- writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
- return 1
- }
- for pushOptIndex := range pushOptCount {
- pushOpt, ok := gitEnv[fmt.Sprintf("GIT_PUSH_OPTION_%d", pushOptIndex)]
- if !ok {
- writeRedError(sshStderr, "Failed to get push option %d", pushOptIndex)
- return 1
- }
- if strings.HasPrefix(pushOpt, "fedid=") {
- fedUserID := strings.TrimPrefix(pushOpt, "fedid=")
- service, username, found := strings.Cut(fedUserID, ":")
- if !found {
- writeRedError(sshStderr, "Invalid federated user identifier %#v does not contain a colon", fedUserID)
- return 1
- }
-
- ok, err := s.fedauth(ctx, packPass.userID, service, username, packPass.pubkey)
- if err != nil {
- writeRedError(sshStderr, "Failed to verify federated user identifier %#v: %v", fedUserID, err)
- return 1
- }
- if !ok {
- writeRedError(sshStderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", fedUserID)
- return 1
- }
-
- break
- }
- if pushOptIndex == pushOptCount-1 {
- writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
- return 1
- }
- }
- }
-
- line, err = stdin.ReadString('\n')
- if errors.Is(err, io.EOF) {
- break
- } else if err != nil {
- writeRedError(sshStderr, "Failed to read pre-receive line: %v", err)
- return 1
- }
- line = line[:len(line)-1]
-
- oldOID, rest, found = strings.Cut(line, " ")
- if !found {
- writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
- return 1
- }
-
- newIOID, refName, found = strings.Cut(rest, " ")
- if !found {
- writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
- return 1
- }
-
- if strings.HasPrefix(refName, "refs/heads/contrib/") {
- if allZero(oldOID) { // New branch
- fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
- var newMRLocalID int
-
- if packPass.userID != 0 {
- err = s.database.QueryRow(ctx,
- "INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING repo_local_id",
- packPass.repoID, packPass.userID, strings.TrimPrefix(refName, "refs/heads/"),
- ).Scan(&newMRLocalID)
- } else {
- err = s.database.QueryRow(ctx,
- "INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING repo_local_id",
- packPass.repoID, strings.TrimPrefix(refName, "refs/heads/"),
- ).Scan(&newMRLocalID)
- }
- if err != nil {
- writeRedError(sshStderr, "Error creating merge request: %v", err)
- return 1
- }
- mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", s.genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRLocalID)
- fmt.Fprintln(sshStderr, ansiec.Blue+"Created merge request at", mergeRequestWebURL+ansiec.Reset)
-
- s.ircBot.Send("PRIVMSG #chat :New merge request at " + mergeRequestWebURL)
- } else { // Existing contrib branch
- var existingMRUser int
- var isAncestor bool
-
- err = s.database.QueryRow(ctx,
- "SELECT COALESCE(creator, 0) FROM merge_requests WHERE source_ref = $1 AND repo_id = $2",
- strings.TrimPrefix(refName, "refs/heads/"), packPass.repoID,
- ).Scan(&existingMRUser)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- writeRedError(sshStderr, "No existing merge request for existing contrib branch: %v", err)
- } else {
- writeRedError(sshStderr, "Error querying for existing merge request: %v", err)
- }
- return 1
- }
- if existingMRUser == 0 {
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs to unowned MR)")
- continue
- }
-
- if existingMRUser != packPass.userID {
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs another user's MR)")
- continue
- }
-
- oldHash = plumbing.NewHash(oldOID)
-
- if oldCommit, err = packPass.repo.CommitObject(oldHash); err != nil {
- writeRedError(sshStderr, "Daemon failed to get old commit: %v", err)
- return 1
- }
-
- // Potential BUG: I'm not sure if new_commit is guaranteed to be
- // detectable as they haven't been merged into the main repo's
- // objects yet. But it seems to work, and I don't think there's
- // any reason for this to only work intermitently.
- newHash = plumbing.NewHash(newIOID)
- if newCommit, err = packPass.repo.CommitObject(newHash); err != nil {
- writeRedError(sshStderr, "Daemon failed to get new commit: %v", err)
- return 1
- }
-
- if isAncestor, err = oldCommit.IsAncestor(newCommit); err != nil {
- writeRedError(sshStderr, "Daemon failed to check if old commit is ancestor: %v", err)
- return 1
- }
-
- if !isAncestor {
- // TODO: Create MR snapshot ref instead
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(force pushes are not supported yet)")
- continue
- }
-
- fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
- }
- } else { // Non-contrib branch
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(you cannot push to branches outside of contrib/*)")
- }
- }
-
- fmt.Fprintln(sshStderr)
- if allOK {
- fmt.Fprintln(sshStderr, "Overall "+ansiec.Green+"ACK"+ansiec.Reset+" (all checks passed)")
- return 0
- }
- fmt.Fprintln(sshStderr, "Overall "+ansiec.Red+"NAK"+ansiec.Reset+" (one or more branches failed checks)")
- return 1
- default:
- fmt.Fprintln(sshStderr, ansiec.Red+"Invalid hook:", args[0]+ansiec.Reset)
- return 1
- }
- }()
-
- fmt.Fprintln(sshStderr)
-
- _, _ = conn.Write([]byte{hookRet})
-}
-
-// serveGitHooks handles connections on the specified network listener and
-// treats incoming connections as those from git hook handlers by spawning
-// sessions. The listener must be a SOCK_STREAM UNIX domain socket. The
-// function itself blocks.
-func (s *Server) serveGitHooks(listener net.Listener) error {
- for {
- conn, err := listener.Accept()
- if err != nil {
- return err
- }
- go s.hooksHandler(conn)
- }
-}
-
-// getUcred fetches connection credentials as a [syscall.Ucred] from a given
-// [net.Conn]. It panics when conn is not a [net.UnixConn].
-func getUcred(conn net.Conn) (ucred *syscall.Ucred, err error) {
- unixConn := conn.(*net.UnixConn)
- var unixConnFD *os.File
-
- if unixConnFD, err = unixConn.File(); err != nil {
- return nil, errGetFD
- }
- defer unixConnFD.Close()
-
- if ucred, err = syscall.GetsockoptUcred(int(unixConnFD.Fd()), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err != nil {
- return nil, errGetUcred
- }
- return ucred, nil
-}
-
-// allZero returns true if all runes in a given string are '0'. The comparison
-// is not constant time and must not be used in contexts where time-based side
-// channel attacks are a concern.
-func allZero(s string) bool {
- for _, r := range s {
- if r != '0' {
- return false
- }
- }
- return true
-}
diff --git a/forged/internal/unsorted/git_hooks_handle_other.go b/forged/internal/unsorted/git_hooks_handle_other.go
deleted file mode 100644
index 70b2072..0000000
--- a/forged/internal/unsorted/git_hooks_handle_other.go
+++ /dev/null
@@ -1,336 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-//
-//go:build !linux
-
-package unsorted
-
-import (
- "bytes"
- "context"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "net"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/jackc/pgx/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/ansiec"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-// hooksHandler handles a connection from hookc via the
-// unix socket.
-func (s *Server) hooksHandler(conn net.Conn) {
- var ctx context.Context
- var cancel context.CancelFunc
- var err error
- var cookie []byte
- var packPass packPass
- var sshStderr io.Writer
- var hookRet byte
-
- defer conn.Close()
- ctx, cancel = context.WithCancel(context.Background())
- defer cancel()
-
- // TODO: ucred-like checks
-
- cookie = make([]byte, 64)
- if _, err = conn.Read(cookie); err != nil {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nFailed to read cookie: %v", err)
- return
- }
-
- {
- var ok bool
- packPass, ok = s.packPasses.Load(misc.BytesToString(cookie))
- if !ok {
- if _, err = conn.Write([]byte{1}); err != nil {
- return
- }
- writeRedError(conn, "\nInvalid handler cookie")
- return
- }
- }
-
- sshStderr = packPass.session.Stderr()
-
- _, _ = sshStderr.Write([]byte{'\n'})
-
- hookRet = func() byte {
- var argc64 uint64
- if err = binary.Read(conn, binary.NativeEndian, &argc64); err != nil {
- writeRedError(sshStderr, "Failed to read argc: %v", err)
- return 1
- }
- var args []string
- for range argc64 {
- var arg bytes.Buffer
- for {
- nextByte := make([]byte, 1)
- n, err := conn.Read(nextByte)
- if err != nil || n != 1 {
- writeRedError(sshStderr, "Failed to read arg: %v", err)
- return 1
- }
- if nextByte[0] == 0 {
- break
- }
- arg.WriteByte(nextByte[0])
- }
- args = append(args, arg.String())
- }
-
- gitEnv := make(map[string]string)
- for {
- var envLine bytes.Buffer
- for {
- nextByte := make([]byte, 1)
- n, err := conn.Read(nextByte)
- if err != nil || n != 1 {
- writeRedError(sshStderr, "Failed to read environment variable: %v", err)
- return 1
- }
- if nextByte[0] == 0 {
- break
- }
- envLine.WriteByte(nextByte[0])
- }
- if envLine.Len() == 0 {
- break
- }
- kv := envLine.String()
- parts := strings.SplitN(kv, "=", 2)
- if len(parts) < 2 {
- writeRedError(sshStderr, "Invalid environment variable line: %v", kv)
- return 1
- }
- gitEnv[parts[0]] = parts[1]
- }
-
- var stdin bytes.Buffer
- if _, err = io.Copy(&stdin, conn); err != nil {
- writeRedError(conn, "Failed to read to the stdin buffer: %v", err)
- }
-
- switch filepath.Base(args[0]) {
- case "pre-receive":
- if packPass.directAccess {
- return 0
- }
- allOK := true
- for {
- var line, oldOID, rest, newIOID, refName string
- var found bool
- var oldHash, newHash plumbing.Hash
- var oldCommit, newCommit *object.Commit
- var pushOptCount int
-
- pushOptCount, err = strconv.Atoi(gitEnv["GIT_PUSH_OPTION_COUNT"])
- if err != nil {
- writeRedError(sshStderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
- return 1
- }
-
- // TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
- // Also it'd be nice to be able to combine users or whatever
- if packPass.contribReq == "federated" && packPass.userType != "federated" && packPass.userType != "registered" {
- if pushOptCount == 0 {
- writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
- return 1
- }
- for pushOptIndex := range pushOptCount {
- pushOpt, ok := gitEnv[fmt.Sprintf("GIT_PUSH_OPTION_%d", pushOptIndex)]
- if !ok {
- writeRedError(sshStderr, "Failed to get push option %d", pushOptIndex)
- return 1
- }
- if strings.HasPrefix(pushOpt, "fedid=") {
- fedUserID := strings.TrimPrefix(pushOpt, "fedid=")
- service, username, found := strings.Cut(fedUserID, ":")
- if !found {
- writeRedError(sshStderr, "Invalid federated user identifier %#v does not contain a colon", fedUserID)
- return 1
- }
-
- ok, err := s.fedauth(ctx, packPass.userID, service, username, packPass.pubkey)
- if err != nil {
- writeRedError(sshStderr, "Failed to verify federated user identifier %#v: %v", fedUserID, err)
- return 1
- }
- if !ok {
- writeRedError(sshStderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", fedUserID)
- return 1
- }
-
- break
- }
- if pushOptIndex == pushOptCount-1 {
- writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
- return 1
- }
- }
- }
-
- line, err = stdin.ReadString('\n')
- if errors.Is(err, io.EOF) {
- break
- } else if err != nil {
- writeRedError(sshStderr, "Failed to read pre-receive line: %v", err)
- return 1
- }
- line = line[:len(line)-1]
-
- oldOID, rest, found = strings.Cut(line, " ")
- if !found {
- writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
- return 1
- }
-
- newIOID, refName, found = strings.Cut(rest, " ")
- if !found {
- writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
- return 1
- }
-
- if strings.HasPrefix(refName, "refs/heads/contrib/") {
- if allZero(oldOID) { // New branch
- fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
- var newMRLocalID int
-
- if packPass.userID != 0 {
- err = s.database.QueryRow(ctx,
- "INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING repo_local_id",
- packPass.repoID, packPass.userID, strings.TrimPrefix(refName, "refs/heads/"),
- ).Scan(&newMRLocalID)
- } else {
- err = s.database.QueryRow(ctx,
- "INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING repo_local_id",
- packPass.repoID, strings.TrimPrefix(refName, "refs/heads/"),
- ).Scan(&newMRLocalID)
- }
- if err != nil {
- writeRedError(sshStderr, "Error creating merge request: %v", err)
- return 1
- }
- mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", s.genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRLocalID)
- fmt.Fprintln(sshStderr, ansiec.Blue+"Created merge request at", mergeRequestWebURL+ansiec.Reset)
-
- s.ircBot.Send("PRIVMSG #chat :New merge request at " + mergeRequestWebURL)
- } else { // Existing contrib branch
- var existingMRUser int
- var isAncestor bool
-
- err = s.database.QueryRow(ctx,
- "SELECT COALESCE(creator, 0) FROM merge_requests WHERE source_ref = $1 AND repo_id = $2",
- strings.TrimPrefix(refName, "refs/heads/"), packPass.repoID,
- ).Scan(&existingMRUser)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- writeRedError(sshStderr, "No existing merge request for existing contrib branch: %v", err)
- } else {
- writeRedError(sshStderr, "Error querying for existing merge request: %v", err)
- }
- return 1
- }
- if existingMRUser == 0 {
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs to unowned MR)")
- continue
- }
-
- if existingMRUser != packPass.userID {
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs another user's MR)")
- continue
- }
-
- oldHash = plumbing.NewHash(oldOID)
-
- if oldCommit, err = packPass.repo.CommitObject(oldHash); err != nil {
- writeRedError(sshStderr, "Daemon failed to get old commit: %v", err)
- return 1
- }
-
- // Potential BUG: I'm not sure if new_commit is guaranteed to be
- // detectable as they haven't been merged into the main repo's
- // objects yet. But it seems to work, and I don't think there's
- // any reason for this to only work intermitently.
- newHash = plumbing.NewHash(newIOID)
- if newCommit, err = packPass.repo.CommitObject(newHash); err != nil {
- writeRedError(sshStderr, "Daemon failed to get new commit: %v", err)
- return 1
- }
-
- if isAncestor, err = oldCommit.IsAncestor(newCommit); err != nil {
- writeRedError(sshStderr, "Daemon failed to check if old commit is ancestor: %v", err)
- return 1
- }
-
- if !isAncestor {
- // TODO: Create MR snapshot ref instead
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(force pushes are not supported yet)")
- continue
- }
-
- fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
- }
- } else { // Non-contrib branch
- allOK = false
- fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(you cannot push to branches outside of contrib/*)")
- }
- }
-
- fmt.Fprintln(sshStderr)
- if allOK {
- fmt.Fprintln(sshStderr, "Overall "+ansiec.Green+"ACK"+ansiec.Reset+" (all checks passed)")
- return 0
- }
- fmt.Fprintln(sshStderr, "Overall "+ansiec.Red+"NAK"+ansiec.Reset+" (one or more branches failed checks)")
- return 1
- default:
- fmt.Fprintln(sshStderr, ansiec.Red+"Invalid hook:", args[0]+ansiec.Reset)
- return 1
- }
- }()
-
- fmt.Fprintln(sshStderr)
-
- _, _ = conn.Write([]byte{hookRet})
-}
-
-// serveGitHooks handles connections on the specified network listener and
-// treats incoming connections as those from git hook handlers by spawning
-// sessions. The listener must be a SOCK_STREAM UNIX domain socket. The
-// function itself blocks.
-func (s *Server) serveGitHooks(listener net.Listener) error {
- for {
- conn, err := listener.Accept()
- if err != nil {
- return err
- }
- go s.hooksHandler(conn)
- }
-}
-
-// allZero returns true if all runes in a given string are '0'. The comparison
-// is not constant time and must not be used in contexts where time-based side
-// channel attacks are a concern.
-func allZero(s string) bool {
- for _, r := range s {
- if r != '0' {
- return false
- }
- }
- return true
-}
diff --git a/forged/internal/unsorted/git_init.go b/forged/internal/unsorted/git_init.go
deleted file mode 100644
index a9bba78..0000000
--- a/forged/internal/unsorted/git_init.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "github.com/go-git/go-git/v5"
- gitConfig "github.com/go-git/go-git/v5/config"
- gitFmtConfig "github.com/go-git/go-git/v5/plumbing/format/config"
-)
-
-// gitInit initializes a bare git repository with the forge-deployed hooks
-// directory as the hooksPath.
-func (s *Server) gitInit(repoPath string) (err error) {
- var repo *git.Repository
- var gitConf *gitConfig.Config
-
- if repo, err = git.PlainInit(repoPath, true); err != nil {
- return err
- }
-
- if gitConf, err = repo.Config(); err != nil {
- return err
- }
-
- gitConf.Raw.SetOption("core", gitFmtConfig.NoSubsection, "hooksPath", s.config.Hooks.Execs)
- gitConf.Raw.SetOption("receive", gitFmtConfig.NoSubsection, "advertisePushOptions", "true")
-
- if err = repo.SetConfig(gitConf); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/forged/internal/unsorted/git_misc.go b/forged/internal/unsorted/git_misc.go
deleted file mode 100644
index dd93726..0000000
--- a/forged/internal/unsorted/git_misc.go
+++ /dev/null
@@ -1,95 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "context"
- "errors"
- "io"
- "iter"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-// openRepo opens a git repository by group and repo name.
-//
-// TODO: This should be deprecated in favor of doing it in the relevant
-// request/router context in the future, as it cannot cover the nuance of
-// fields needed.
-func (s *Server) openRepo(ctx context.Context, groupPath []string, repoName string) (repo *git.Repository, description string, repoID int, fsPath string, err error) {
- err = s.database.QueryRow(ctx, `
-WITH RECURSIVE group_path_cte AS (
- -- Start: match the first name in the path where parent_group IS NULL
- SELECT
- id,
- parent_group,
- name,
- 1 AS depth
- FROM groups
- WHERE name = ($1::text[])[1]
- AND parent_group IS NULL
-
- UNION ALL
-
- -- Recurse: join next segment of the path
- SELECT
- g.id,
- g.parent_group,
- g.name,
- group_path_cte.depth + 1
- FROM groups g
- JOIN group_path_cte ON g.parent_group = group_path_cte.id
- WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
- AND group_path_cte.depth + 1 <= cardinality($1::text[])
-)
-SELECT
- r.filesystem_path,
- COALESCE(r.description, ''),
- r.id
-FROM group_path_cte g
-JOIN repos r ON r.group_id = g.id
-WHERE g.depth = cardinality($1::text[])
- AND r.name = $2
- `, pgtype.FlatArray[string](groupPath), repoName).Scan(&fsPath, &description, &repoID)
- if err != nil {
- return
- }
-
- repo, err = git.PlainOpen(fsPath)
- return
-}
-
-// commitIterSeqErr creates an [iter.Seq[*object.Commit]] from an
-// [object.CommitIter], and additionally returns a pointer to error.
-// The pointer to error is guaranteed to be populated with either nil or the
-// error returned by the commit iterator after the returned iterator is
-// finished.
-func commitIterSeqErr(ctx context.Context, commitIter object.CommitIter) (iter.Seq[*object.Commit], *error) {
- var err error
- return func(yield func(*object.Commit) bool) {
- for {
- commit, err2 := commitIter.Next()
- if err2 != nil {
- if errors.Is(err2, io.EOF) {
- return
- }
- err = err2
- return
- }
-
- select {
- case <-ctx.Done():
- err = ctx.Err()
- return
- default:
- }
-
- if !yield(commit) {
- return
- }
- }
- }, &err
-}
diff --git a/forged/internal/unsorted/git_plumbing.go b/forged/internal/unsorted/git_plumbing.go
deleted file mode 100644
index e7ebe8f..0000000
--- a/forged/internal/unsorted/git_plumbing.go
+++ /dev/null
@@ -1,188 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "bytes"
- "context"
- "encoding/hex"
- "errors"
- "os"
- "os/exec"
- "path"
- "sort"
- "strings"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-func writeTree(ctx context.Context, repoPath string, entries []treeEntry) (string, error) {
- var buf bytes.Buffer
-
- sort.Slice(entries, func(i, j int) bool {
- nameI, nameJ := entries[i].name, entries[j].name
-
- if nameI == nameJ { // meh
- return !(entries[i].mode == "40000") && (entries[j].mode == "40000")
- }
-
- if strings.HasPrefix(nameJ, nameI) && len(nameI) < len(nameJ) {
- return !(entries[i].mode == "40000")
- }
-
- if strings.HasPrefix(nameI, nameJ) && len(nameJ) < len(nameI) {
- return entries[j].mode == "40000"
- }
-
- return nameI < nameJ
- })
-
- for _, e := range entries {
- buf.WriteString(e.mode)
- buf.WriteByte(' ')
- buf.WriteString(e.name)
- buf.WriteByte(0)
- buf.Write(e.sha)
- }
-
- cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "-t", "tree", "--stdin")
- cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath)
- cmd.Stdin = &buf
-
- var out bytes.Buffer
- cmd.Stdout = &out
- if err := cmd.Run(); err != nil {
- return "", err
- }
- return strings.TrimSpace(out.String()), nil
-}
-
-func buildTreeRecursive(ctx context.Context, repoPath, baseTree string, updates map[string][]byte) (string, error) {
- treeCache := make(map[string][]treeEntry)
-
- var walk func(string, string) error
- walk = func(prefix, sha string) error {
- cmd := exec.CommandContext(ctx, "git", "cat-file", "tree", sha)
- cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath)
- var out bytes.Buffer
- cmd.Stdout = &out
- if err := cmd.Run(); err != nil {
- return err
- }
- data := out.Bytes()
- i := 0
- var entries []treeEntry
- for i < len(data) {
- modeEnd := bytes.IndexByte(data[i:], ' ')
- if modeEnd < 0 {
- return errors.New("invalid tree format")
- }
- mode := misc.BytesToString(data[i : i+modeEnd])
- i += modeEnd + 1
-
- nameEnd := bytes.IndexByte(data[i:], 0)
- if nameEnd < 0 {
- return errors.New("missing null after filename")
- }
- name := misc.BytesToString(data[i : i+nameEnd])
- i += nameEnd + 1
-
- if i+20 > len(data) {
- return errors.New("unexpected EOF in SHA")
- }
- shaBytes := data[i : i+20]
- i += 20
-
- entries = append(entries, treeEntry{
- mode: mode,
- name: name,
- sha: shaBytes,
- })
-
- if mode == "40000" {
- subPrefix := path.Join(prefix, name)
- if err := walk(subPrefix, hex.EncodeToString(shaBytes)); err != nil {
- return err
- }
- }
- }
- treeCache[prefix] = entries
- return nil
- }
-
- if err := walk("", baseTree); err != nil {
- return "", err
- }
-
- for filePath, blobSha := range updates {
- parts := strings.Split(filePath, "/")
- dir := strings.Join(parts[:len(parts)-1], "/")
- name := parts[len(parts)-1]
-
- entries := treeCache[dir]
- found := false
- for i, e := range entries {
- if e.name == name {
- if blobSha == nil {
- // Remove TODO
- entries = append(entries[:i], entries[i+1:]...)
- } else {
- entries[i].sha = blobSha
- }
- found = true
- break
- }
- }
- if !found && blobSha != nil {
- entries = append(entries, treeEntry{
- mode: "100644",
- name: name,
- sha: blobSha,
- })
- }
- treeCache[dir] = entries
- }
-
- built := make(map[string][]byte)
- var build func(string) ([]byte, error)
- build = func(prefix string) ([]byte, error) {
- entries := treeCache[prefix]
- for i, e := range entries {
- if e.mode == "40000" {
- subPrefix := path.Join(prefix, e.name)
- if sha, ok := built[subPrefix]; ok {
- entries[i].sha = sha
- continue
- }
- newShaStr, err := build(subPrefix)
- if err != nil {
- return nil, err
- }
- entries[i].sha = newShaStr
- }
- }
- shaStr, err := writeTree(ctx, repoPath, entries)
- if err != nil {
- return nil, err
- }
- shaBytes, err := hex.DecodeString(shaStr)
- if err != nil {
- return nil, err
- }
- built[prefix] = shaBytes
- return shaBytes, nil
- }
-
- rootShaBytes, err := build("")
- if err != nil {
- return "", err
- }
- return hex.EncodeToString(rootShaBytes), nil
-}
-
-type treeEntry struct {
- mode string // like "100644"
- name string // individual name
- sha []byte
-}
diff --git a/forged/internal/unsorted/git_ref.go b/forged/internal/unsorted/git_ref.go
deleted file mode 100644
index d9735ba..0000000
--- a/forged/internal/unsorted/git_ref.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
-)
-
-// getRefHash returns the hash of a reference given its
-// type and name as supplied in URL queries.
-func getRefHash(repo *git.Repository, refType, refName string) (refHash plumbing.Hash, err error) {
- var ref *plumbing.Reference
- switch refType {
- case "":
- if ref, err = repo.Head(); err != nil {
- return
- }
- refHash = ref.Hash()
- case "commit":
- refHash = plumbing.NewHash(refName)
- case "branch":
- if ref, err = repo.Reference(plumbing.NewBranchReferenceName(refName), true); err != nil {
- return
- }
- refHash = ref.Hash()
- case "tag":
- if ref, err = repo.Reference(plumbing.NewTagReferenceName(refName), true); err != nil {
- return
- }
- refHash = ref.Hash()
- default:
- panic("Invalid ref type " + refType)
- }
- return
-}
diff --git a/forged/internal/unsorted/http_auth.go b/forged/internal/unsorted/http_auth.go
deleted file mode 100644
index b0afa05..0000000
--- a/forged/internal/unsorted/http_auth.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-)
-
-// getUserFromRequest returns the user ID and username associated with the
-// session cookie in a given [http.Request].
-func (s *Server) getUserFromRequest(request *http.Request) (id int, username string, err error) {
- var sessionCookie *http.Cookie
-
- if sessionCookie, err = request.Cookie("session"); err != nil {
- return
- }
-
- err = s.database.QueryRow(
- request.Context(),
- "SELECT user_id, COALESCE(username, '') FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.session_id = $1;",
- sessionCookie.Value,
- ).Scan(&id, &username)
-
- return
-}
diff --git a/forged/internal/unsorted/http_handle_branches.go b/forged/internal/unsorted/http_handle_branches.go
deleted file mode 100644
index 704e1d8..0000000
--- a/forged/internal/unsorted/http_handle_branches.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
- "strings"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/storer"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-// httpHandleRepoBranches provides the branches page in repos.
-func (s *Server) httpHandleRepoBranches(writer http.ResponseWriter, _ *http.Request, params map[string]any) {
- var repo *git.Repository
- var repoName string
- var groupPath []string
- var err error
- var notes []string
- var branches []string
- var branchesIter storer.ReferenceIter
-
- repo, repoName, groupPath = params["repo"].(*git.Repository), params["repo_name"].(string), params["group_path"].([]string)
-
- if strings.Contains(repoName, "\n") || misc.SliceContainsNewlines(groupPath) {
- notes = append(notes, "Path contains newlines; HTTP Git access impossible")
- }
-
- branchesIter, err = repo.Branches()
- if err == nil {
- _ = branchesIter.ForEach(func(branch *plumbing.Reference) error {
- branches = append(branches, branch.Name().Short())
- return nil
- })
- }
- params["branches"] = branches
-
- params["http_clone_url"] = s.genHTTPRemoteURL(groupPath, repoName)
- params["ssh_clone_url"] = s.genSSHRemoteURL(groupPath, repoName)
- params["notes"] = notes
-
- s.renderTemplate(writer, "repo_branches", params)
-}
diff --git a/forged/internal/unsorted/http_handle_group_index.go b/forged/internal/unsorted/http_handle_group_index.go
deleted file mode 100644
index ce28a1c..0000000
--- a/forged/internal/unsorted/http_handle_group_index.go
+++ /dev/null
@@ -1,196 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "errors"
- "net/http"
- "path/filepath"
- "strconv"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleGroupIndex provides index pages for groups, which includes a list
-// of its subgroups and repos, as well as a form for group maintainers to
-// create repos.
-func (s *Server) httpHandleGroupIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var groupPath []string
- var repos []nameDesc
- var subgroups []nameDesc
- var err error
- var groupID int
- var groupDesc string
-
- groupPath = params["group_path"].([]string)
-
- // The group itself
- err = s.database.QueryRow(request.Context(), `
- WITH RECURSIVE group_path_cte AS (
- SELECT
- id,
- parent_group,
- name,
- 1 AS depth
- FROM groups
- WHERE name = ($1::text[])[1]
- AND parent_group IS NULL
-
- UNION ALL
-
- SELECT
- g.id,
- g.parent_group,
- g.name,
- group_path_cte.depth + 1
- FROM groups g
- JOIN group_path_cte ON g.parent_group = group_path_cte.id
- WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
- AND group_path_cte.depth + 1 <= cardinality($1::text[])
- )
- SELECT c.id, COALESCE(g.description, '')
- FROM group_path_cte c
- JOIN groups g ON g.id = c.id
- WHERE c.depth = cardinality($1::text[])
- `,
- pgtype.FlatArray[string](groupPath),
- ).Scan(&groupID, &groupDesc)
-
- if errors.Is(err, pgx.ErrNoRows) {
- web.ErrorPage404(s.templates, writer, params)
- return
- } else if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting group: "+err.Error())
- return
- }
-
- // ACL
- var count int
- err = s.database.QueryRow(request.Context(), `
- SELECT COUNT(*)
- FROM user_group_roles
- WHERE user_id = $1
- AND group_id = $2
- `, params["user_id"].(int), groupID).Scan(&count)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
- return
- }
- directAccess := (count > 0)
-
- if request.Method == http.MethodPost {
- if !directAccess {
- web.ErrorPage403(s.templates, writer, params, "You do not have direct access to this group")
- return
- }
-
- repoName := request.FormValue("repo_name")
- repoDesc := request.FormValue("repo_desc")
- contribReq := request.FormValue("repo_contrib")
- if repoName == "" {
- web.ErrorPage400(s.templates, writer, params, "Repo name is required")
- return
- }
-
- var newRepoID int
- err := s.database.QueryRow(
- request.Context(),
- `INSERT INTO repos (name, description, group_id, contrib_requirements)
- VALUES ($1, $2, $3, $4)
- RETURNING id`,
- repoName,
- repoDesc,
- groupID,
- contribReq,
- ).Scan(&newRepoID)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error creating repo: "+err.Error())
- return
- }
-
- filePath := filepath.Join(s.config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")
-
- _, err = s.database.Exec(
- request.Context(),
- `UPDATE repos
- SET filesystem_path = $1
- WHERE id = $2`,
- filePath,
- newRepoID,
- )
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error updating repo path: "+err.Error())
- return
- }
-
- if err = s.gitInit(filePath); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error initializing repo: "+err.Error())
- return
- }
-
- misc.RedirectUnconditionally(writer, request)
- return
- }
-
- // Repos
- var rows pgx.Rows
- rows, err = s.database.Query(request.Context(), `
- SELECT name, COALESCE(description, '')
- FROM repos
- WHERE group_id = $1
- `, groupID)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting repos: "+err.Error())
- return
- }
- defer rows.Close()
-
- for rows.Next() {
- var name, description string
- if err = rows.Scan(&name, &description); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting repos: "+err.Error())
- return
- }
- repos = append(repos, nameDesc{name, description})
- }
- if err = rows.Err(); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting repos: "+err.Error())
- return
- }
-
- // Subgroups
- rows, err = s.database.Query(request.Context(), `
- SELECT name, COALESCE(description, '')
- FROM groups
- WHERE parent_group = $1
- `, groupID)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error())
- return
- }
- defer rows.Close()
-
- for rows.Next() {
- var name, description string
- if err = rows.Scan(&name, &description); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error())
- return
- }
- subgroups = append(subgroups, nameDesc{name, description})
- }
- if err = rows.Err(); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error())
- return
- }
-
- params["repos"] = repos
- params["subgroups"] = subgroups
- params["description"] = groupDesc
- params["direct_access"] = directAccess
-
- s.renderTemplate(writer, "group", params)
-}
diff --git a/forged/internal/unsorted/http_handle_index.go b/forged/internal/unsorted/http_handle_index.go
deleted file mode 100644
index a3141f4..0000000
--- a/forged/internal/unsorted/http_handle_index.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleIndex provides the main index page which includes a list of groups
-// and some global information such as SSH keys.
-func (s *Server) httpHandleIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var err error
- var groups []nameDesc
-
- groups, err = s.queryNameDesc(request.Context(), "SELECT name, COALESCE(description, '') FROM groups WHERE parent_group IS NULL")
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error querying groups: "+err.Error())
- return
- }
- params["groups"] = groups
-
- s.renderTemplate(writer, "index", params)
-}
diff --git a/forged/internal/unsorted/http_handle_login.go b/forged/internal/unsorted/http_handle_login.go
deleted file mode 100644
index 8adbe17..0000000
--- a/forged/internal/unsorted/http_handle_login.go
+++ /dev/null
@@ -1,108 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "crypto/rand"
- "encoding/base64"
- "errors"
- "fmt"
- "net/http"
- "time"
-
- "github.com/jackc/pgx/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/argon2id"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleLogin provides the login page for local users.
-func (s *Server) httpHandleLogin(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var username, password string
- var userID int
- var passwordHash string
- var err error
- var passwordMatches bool
- var cookieValue string
- var now time.Time
- var expiry time.Time
- var cookie http.Cookie
-
- if request.Method != http.MethodPost {
- s.renderTemplate(writer, "login", params)
- return
- }
-
- username = request.PostFormValue("username")
- password = request.PostFormValue("password")
-
- err = s.database.QueryRow(request.Context(),
- "SELECT id, COALESCE(password, '') FROM users WHERE username = $1",
- username,
- ).Scan(&userID, &passwordHash)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- params["login_error"] = "Unknown username"
- s.renderTemplate(writer, "login", params)
- return
- }
- web.ErrorPage500(s.templates, writer, params, "Error querying user information: "+err.Error())
- return
- }
- if passwordHash == "" {
- params["login_error"] = "User has no password"
- s.renderTemplate(writer, "login", params)
- return
- }
-
- if passwordMatches, err = argon2id.ComparePasswordAndHash(password, passwordHash); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error comparing password and hash: "+err.Error())
- return
- }
-
- if !passwordMatches {
- params["login_error"] = "Invalid password"
- s.renderTemplate(writer, "login", params)
- return
- }
-
- if cookieValue, err = randomUrlsafeStr(16); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting random string: "+err.Error())
- return
- }
-
- now = time.Now()
- expiry = now.Add(time.Duration(s.config.HTTP.CookieExpiry) * time.Second)
-
- cookie = http.Cookie{
- Name: "session",
- Value: cookieValue,
- SameSite: http.SameSiteLaxMode,
- HttpOnly: true,
- Secure: false, // TODO
- Expires: expiry,
- Path: "/",
- } //exhaustruct:ignore
-
- http.SetCookie(writer, &cookie)
-
- _, err = s.database.Exec(request.Context(), "INSERT INTO sessions (user_id, session_id) VALUES ($1, $2)", userID, cookieValue)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error inserting session: "+err.Error())
- return
- }
-
- http.Redirect(writer, request, "/", http.StatusSeeOther)
-}
-
-// randomUrlsafeStr generates a random string of the given entropic size
-// using the URL-safe base64 encoding. The actual size of the string returned
-// will be 4*sz.
-func randomUrlsafeStr(sz int) (string, error) {
- r := make([]byte, 3*sz)
- _, err := rand.Read(r)
- if err != nil {
- return "", fmt.Errorf("error generating random string: %w", err)
- }
- return base64.RawURLEncoding.EncodeToString(r), nil
-}
diff --git a/forged/internal/unsorted/http_handle_repo_commit.go b/forged/internal/unsorted/http_handle_repo_commit.go
deleted file mode 100644
index 2afdf3a..0000000
--- a/forged/internal/unsorted/http_handle_repo_commit.go
+++ /dev/null
@@ -1,146 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "fmt"
- "net/http"
- "strings"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/filemode"
- "github.com/go-git/go-git/v5/plumbing/format/diff"
- "github.com/go-git/go-git/v5/plumbing/object"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- "go.lindenii.runxiyu.org/forge/forged/internal/oldgit"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// usableFilePatch is a [diff.FilePatch] that is structured in a way more
-// friendly for use in HTML templates.
-type usableFilePatch struct {
- From diff.File
- To diff.File
- Chunks []usableChunk
-}
-
-// usableChunk is a [diff.Chunk] that is structured in a way more friendly for
-// use in HTML templates.
-type usableChunk struct {
- Operation diff.Operation
- Content string
-}
-
-func (s *Server) httpHandleRepoCommit(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var repo *git.Repository
- var commitIDStrSpec, commitIDStrSpecNoSuffix string
- var commitID plumbing.Hash
- var parentCommitHash plumbing.Hash
- var commitObj *object.Commit
- var commitIDStr string
- var err error
- var patch *object.Patch
-
- repo, commitIDStrSpec = params["repo"].(*git.Repository), params["commit_id"].(string)
-
- commitIDStrSpecNoSuffix = strings.TrimSuffix(commitIDStrSpec, ".patch")
- commitID = plumbing.NewHash(commitIDStrSpecNoSuffix)
- if commitObj, err = repo.CommitObject(commitID); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting commit object: "+err.Error())
- return
- }
- if commitIDStrSpecNoSuffix != commitIDStrSpec {
- var patchStr string
- if patchStr, err = oldgit.FmtCommitPatch(commitObj); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error formatting patch: "+err.Error())
- return
- }
- fmt.Fprintln(writer, patchStr)
- return
- }
- commitIDStr = commitObj.Hash.String()
-
- if commitIDStr != commitIDStrSpec {
- http.Redirect(writer, request, commitIDStr, http.StatusSeeOther)
- return
- }
-
- params["commit_object"] = commitObj
- params["commit_id"] = commitIDStr
-
- parentCommitHash, patch, err = oldgit.CommitToPatch(commitObj)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting patch from commit: "+err.Error())
- return
- }
- params["parent_commit_hash"] = parentCommitHash.String()
- params["patch"] = patch
-
- params["file_patches"] = makeUsableFilePatches(patch)
-
- s.renderTemplate(writer, "repo_commit", params)
-}
-
-type fakeDiffFile struct {
- hash plumbing.Hash
- mode filemode.FileMode
- path string
-}
-
-func (f fakeDiffFile) Hash() plumbing.Hash {
- return f.hash
-}
-
-func (f fakeDiffFile) Mode() filemode.FileMode {
- return f.mode
-}
-
-func (f fakeDiffFile) Path() string {
- return f.path
-}
-
-var nullFakeDiffFile = fakeDiffFile{ //nolint:gochecknoglobals
- hash: plumbing.NewHash("0000000000000000000000000000000000000000"),
- mode: misc.FirstOrPanic(filemode.New("100644")),
- path: "",
-}
-
-func makeUsableFilePatches(patch diff.Patch) (usableFilePatches []usableFilePatch) {
- // TODO: Remove unnecessary context
- // TODO: Prepend "+"/"-"/" " instead of solely distinguishing based on color
-
- for _, filePatch := range patch.FilePatches() {
- var fromFile, toFile diff.File
- var ufp usableFilePatch
- chunks := []usableChunk{}
-
- fromFile, toFile = filePatch.Files()
- if fromFile == nil {
- fromFile = nullFakeDiffFile
- }
- if toFile == nil {
- toFile = nullFakeDiffFile
- }
- for _, chunk := range filePatch.Chunks() {
- var content string
-
- content = chunk.Content()
- if len(content) > 0 && content[0] == '\n' {
- content = "\n" + content
- } // Horrible hack to fix how browsers newlines that immediately proceed <pre>
- chunks = append(chunks, usableChunk{
- Operation: chunk.Type(),
- Content: content,
- })
- }
- ufp = usableFilePatch{
- Chunks: chunks,
- From: fromFile,
- To: toFile,
- }
- usableFilePatches = append(usableFilePatches, ufp)
- }
- return
-}
diff --git a/forged/internal/unsorted/http_handle_repo_contrib_index.go b/forged/internal/unsorted/http_handle_repo_contrib_index.go
deleted file mode 100644
index 5c68c08..0000000
--- a/forged/internal/unsorted/http_handle_repo_contrib_index.go
+++ /dev/null
@@ -1,52 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-
- "github.com/jackc/pgx/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// idTitleStatus describes properties of a merge request that needs to be
-// present in MR listings.
-type idTitleStatus struct {
- ID int
- Title string
- Status string
-}
-
-// httpHandleRepoContribIndex provides an index to merge requests of a repo.
-func (s *Server) httpHandleRepoContribIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var rows pgx.Rows
- var result []idTitleStatus
- var err error
-
- if rows, err = s.database.Query(request.Context(),
- "SELECT repo_local_id, COALESCE(title, 'Untitled'), status FROM merge_requests WHERE repo_id = $1",
- params["repo_id"],
- ); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error querying merge requests: "+err.Error())
- return
- }
- defer rows.Close()
-
- for rows.Next() {
- var mrID int
- var mrTitle, mrStatus string
- if err = rows.Scan(&mrID, &mrTitle, &mrStatus); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error scanning merge request: "+err.Error())
- return
- }
- result = append(result, idTitleStatus{mrID, mrTitle, mrStatus})
- }
- if err = rows.Err(); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error ranging over merge requests: "+err.Error())
- return
- }
- params["merge_requests"] = result
-
- s.renderTemplate(writer, "repo_contrib_index", params)
-}
diff --git a/forged/internal/unsorted/http_handle_repo_contrib_one.go b/forged/internal/unsorted/http_handle_repo_contrib_one.go
deleted file mode 100644
index 1d733b0..0000000
--- a/forged/internal/unsorted/http_handle_repo_contrib_one.go
+++ /dev/null
@@ -1,98 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
- "strconv"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleRepoContribOne provides an interface to each merge request of a
-// repo.
-func (s *Server) httpHandleRepoContribOne(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- var mrIDStr string
- var mrIDInt int
- var err error
- var title, status, srcRefStr, dstBranchStr string
- var repo *git.Repository
- var srcRefHash plumbing.Hash
- var dstBranchHash plumbing.Hash
- var srcCommit, dstCommit, mergeBaseCommit *object.Commit
- var mergeBases []*object.Commit
-
- mrIDStr = params["mr_id"].(string)
- mrIDInt64, err := strconv.ParseInt(mrIDStr, 10, strconv.IntSize)
- if err != nil {
- web.ErrorPage400(s.templates, writer, params, "Merge request ID not an integer")
- return
- }
- mrIDInt = int(mrIDInt64)
-
- if err = s.database.QueryRow(request.Context(),
- "SELECT COALESCE(title, ''), status, source_ref, COALESCE(destination_branch, '') FROM merge_requests WHERE repo_id = $1 AND repo_local_id = $2",
- params["repo_id"], mrIDInt,
- ).Scan(&title, &status, &srcRefStr, &dstBranchStr); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error querying merge request: "+err.Error())
- return
- }
-
- repo = params["repo"].(*git.Repository)
-
- if srcRefHash, err = getRefHash(repo, "branch", srcRefStr); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting source ref hash: "+err.Error())
- return
- }
- if srcCommit, err = repo.CommitObject(srcRefHash); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting source commit: "+err.Error())
- return
- }
- params["source_commit"] = srcCommit
-
- if dstBranchStr == "" {
- dstBranchStr = "HEAD"
- dstBranchHash, err = getRefHash(repo, "", "")
- } else {
- dstBranchHash, err = getRefHash(repo, "branch", dstBranchStr)
- }
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting destination branch hash: "+err.Error())
- return
- }
-
- if dstCommit, err = repo.CommitObject(dstBranchHash); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting destination commit: "+err.Error())
- return
- }
- params["destination_commit"] = dstCommit
-
- if mergeBases, err = srcCommit.MergeBase(dstCommit); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting merge base: "+err.Error())
- return
- }
-
- if len(mergeBases) < 1 {
- web.ErrorPage500(s.templates, writer, params, "No merge base found for this merge request; these two branches do not share any common history")
- // TODO
- return
- }
-
- mergeBaseCommit = mergeBases[0]
- params["merge_base"] = mergeBaseCommit
-
- patch, err := mergeBaseCommit.Patch(srcCommit)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting patch: "+err.Error())
- return
- }
- params["file_patches"] = makeUsableFilePatches(patch)
-
- params["mr_title"], params["mr_status"], params["mr_source_ref"], params["mr_destination_branch"] = title, status, srcRefStr, dstBranchStr
-
- s.renderTemplate(writer, "repo_contrib_one", params)
-}
diff --git a/forged/internal/unsorted/http_handle_repo_index.go b/forged/internal/unsorted/http_handle_repo_index.go
deleted file mode 100644
index dd46dfe..0000000
--- a/forged/internal/unsorted/http_handle_repo_index.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/git2c"
- "go.lindenii.runxiyu.org/forge/forged/internal/render"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleRepoIndex provides the front page of a repo using git2d.
-func (s *Server) httpHandleRepoIndex(w http.ResponseWriter, req *http.Request, params map[string]any) {
- repoName := params["repo_name"].(string)
- groupPath := params["group_path"].([]string)
-
- _, repoPath, _, _, _, _, _ := s.getRepoInfo(req.Context(), groupPath, repoName, "") // TODO: Don't use getRepoInfo
-
- client, err := git2c.NewClient(s.config.Git.Socket)
- if err != nil {
- web.ErrorPage500(s.templates, w, params, err.Error())
- return
- }
- defer client.Close()
-
- commits, readme, err := client.CmdIndex(repoPath)
- if err != nil {
- web.ErrorPage500(s.templates, w, params, err.Error())
- return
- }
-
- params["commits"] = commits
- params["readme_filename"] = readme.Filename
- _, params["readme"] = render.Readme(readme.Content, readme.Filename)
-
- s.renderTemplate(w, "repo_index", params)
-
- // TODO: Caching
-}
diff --git a/forged/internal/unsorted/http_handle_repo_info.go b/forged/internal/unsorted/http_handle_repo_info.go
deleted file mode 100644
index e23b1d2..0000000
--- a/forged/internal/unsorted/http_handle_repo_info.go
+++ /dev/null
@@ -1,107 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "fmt"
- "io"
- "net/http"
- "os/exec"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-// httpHandleRepoInfo provides advertised refs of a repo for use in Git's Smart
-// HTTP protocol.
-//
-// TODO: Reject access from web browsers.
-func (s *Server) httpHandleRepoInfo(writer http.ResponseWriter, request *http.Request, params map[string]any) (err error) {
- groupPath := params["group_path"].([]string)
- repoName := params["repo_name"].(string)
- var repoPath string
-
- if err := s.database.QueryRow(request.Context(), `
- WITH RECURSIVE group_path_cte AS (
- -- Start: match the first name in the path where parent_group IS NULL
- SELECT
- id,
- parent_group,
- name,
- 1 AS depth
- FROM groups
- WHERE name = ($1::text[])[1]
- AND parent_group IS NULL
-
- UNION ALL
-
- -- Recurse: jion next segment of the path
- SELECT
- g.id,
- g.parent_group,
- g.name,
- group_path_cte.depth + 1
- FROM groups g
- JOIN group_path_cte ON g.parent_group = group_path_cte.id
- WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
- AND group_path_cte.depth + 1 <= cardinality($1::text[])
- )
- SELECT r.filesystem_path
- FROM group_path_cte c
- JOIN repos r ON r.group_id = c.id
- WHERE c.depth = cardinality($1::text[])
- AND r.name = $2
- `,
- pgtype.FlatArray[string](groupPath),
- repoName,
- ).Scan(&repoPath); err != nil {
- return err
- }
-
- writer.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
- writer.WriteHeader(http.StatusOK)
-
- cmd := exec.Command("git", "upload-pack", "--stateless-rpc", "--advertise-refs", repoPath)
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
- defer func() {
- _ = stdout.Close()
- }()
- cmd.Stderr = cmd.Stdout
-
- if err = cmd.Start(); err != nil {
- return err
- }
-
- if err = packLine(writer, "# service=git-upload-pack\n"); err != nil {
- return err
- }
-
- if err = packFlush(writer); err != nil {
- return
- }
-
- if _, err = io.Copy(writer, stdout); err != nil {
- return err
- }
-
- if err = cmd.Wait(); err != nil {
- return err
- }
-
- return nil
-}
-
-// Taken from https://github.com/icyphox/legit, MIT license.
-func packLine(w io.Writer, s string) error {
- _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s)
- return err
-}
-
-// Taken from https://github.com/icyphox/legit, MIT license.
-func packFlush(w io.Writer) error {
- _, err := fmt.Fprint(w, "0000")
- return err
-}
diff --git a/forged/internal/unsorted/http_handle_repo_log.go b/forged/internal/unsorted/http_handle_repo_log.go
deleted file mode 100644
index 5d90871..0000000
--- a/forged/internal/unsorted/http_handle_repo_log.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleRepoLog provides a page with a complete Git log.
-//
-// TODO: This currently provides all commits in the branch. It should be
-// paginated and cached instead.
-func (s *Server) httpHandleRepoLog(writer http.ResponseWriter, req *http.Request, params map[string]any) {
- var repo *git.Repository
- var refHash plumbing.Hash
- var err error
-
- repo = params["repo"].(*git.Repository)
-
- if refHash, err = getRefHash(repo, params["ref_type"].(string), params["ref_name"].(string)); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting ref hash: "+err.Error())
- return
- }
-
- logOptions := git.LogOptions{From: refHash} //exhaustruct:ignore
- commitIter, err := repo.Log(&logOptions)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error getting recent commits: "+err.Error())
- return
- }
- params["commits"], params["commits_err"] = commitIterSeqErr(req.Context(), commitIter)
-
- s.renderTemplate(writer, "repo_log", params)
-}
diff --git a/forged/internal/unsorted/http_handle_repo_raw.go b/forged/internal/unsorted/http_handle_repo_raw.go
deleted file mode 100644
index 1127284..0000000
--- a/forged/internal/unsorted/http_handle_repo_raw.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "fmt"
- "html/template"
- "net/http"
- "strings"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/git2c"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleRepoRaw serves raw files, or directory listings that point to raw
-// files.
-func (s *Server) httpHandleRepoRaw(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- repoName := params["repo_name"].(string)
- groupPath := params["group_path"].([]string)
- rawPathSpec := params["rest"].(string)
- pathSpec := strings.TrimSuffix(rawPathSpec, "/")
- params["path_spec"] = pathSpec
-
- _, repoPath, _, _, _, _, _ := s.getRepoInfo(request.Context(), groupPath, repoName, "")
-
- client, err := git2c.NewClient(s.config.Git.Socket)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- return
- }
- defer client.Close()
-
- files, content, err := client.CmdTreeRaw(repoPath, pathSpec)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- return
- }
-
- switch {
- case files != nil:
- params["files"] = files
- params["readme_filename"] = "README.md"
- params["readme"] = template.HTML("<p>README rendering here is WIP again</p>") // TODO
- s.renderTemplate(writer, "repo_raw_dir", params)
- case content != "":
- if misc.RedirectNoDir(writer, request) {
- return
- }
- writer.Header().Set("Content-Type", "application/octet-stream")
- fmt.Fprint(writer, content)
- default:
- web.ErrorPage500(s.templates, writer, params, "Unknown error fetching repo raw data")
- }
-}
diff --git a/forged/internal/unsorted/http_handle_repo_tree.go b/forged/internal/unsorted/http_handle_repo_tree.go
deleted file mode 100644
index 4799ccb..0000000
--- a/forged/internal/unsorted/http_handle_repo_tree.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "html/template"
- "net/http"
- "strings"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/git2c"
- "go.lindenii.runxiyu.org/forge/forged/internal/render"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleRepoTree provides a friendly, syntax-highlighted view of
-// individual files, and provides directory views that link to these files.
-//
-// TODO: Do not highlight files that are too large.
-func (s *Server) httpHandleRepoTree(writer http.ResponseWriter, request *http.Request, params map[string]any) {
- repoName := params["repo_name"].(string)
- groupPath := params["group_path"].([]string)
- rawPathSpec := params["rest"].(string)
- pathSpec := strings.TrimSuffix(rawPathSpec, "/")
- params["path_spec"] = pathSpec
-
- _, repoPath, _, _, _, _, _ := s.getRepoInfo(request.Context(), groupPath, repoName, "")
-
- client, err := git2c.NewClient(s.config.Git.Socket)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- return
- }
- defer client.Close()
-
- files, content, err := client.CmdTreeRaw(repoPath, pathSpec)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- return
- }
-
- switch {
- case files != nil:
- params["files"] = files
- params["readme_filename"] = "README.md"
- params["readme"] = template.HTML("<p>README rendering here is WIP again</p>") // TODO
- s.renderTemplate(writer, "repo_tree_dir", params)
- case content != "":
- rendered := render.Highlight(pathSpec, content)
- params["file_contents"] = rendered
- s.renderTemplate(writer, "repo_tree_file", params)
- default:
- web.ErrorPage500(s.templates, writer, params, "Unknown object type, something is seriously wrong")
- }
-}
diff --git a/forged/internal/unsorted/http_handle_repo_upload_pack.go b/forged/internal/unsorted/http_handle_repo_upload_pack.go
deleted file mode 100644
index 914c9cc..0000000
--- a/forged/internal/unsorted/http_handle_repo_upload_pack.go
+++ /dev/null
@@ -1,120 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "bytes"
- "compress/gzip"
- "compress/zlib"
- "fmt"
- "io"
- "log"
- "net/http"
- "os"
- "os/exec"
- "strings"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-// httpHandleUploadPack handles incoming Git fetch/pull/clone's over the Smart
-// HTTP protocol.
-func (s *Server) httpHandleUploadPack(writer http.ResponseWriter, request *http.Request, params map[string]any) (err error) {
- if ct := request.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-git-upload-pack-request") {
- http.Error(writer, "bad content-type", http.StatusUnsupportedMediaType)
- return nil
- }
-
- decoded, err := decodeBody(request)
- if err != nil {
- http.Error(writer, "cannot decode request body", http.StatusBadRequest)
- return err
- }
- defer decoded.Close()
-
- var groupPath []string
- var repoName string
- var repoPath string
- var cmd *exec.Cmd
-
- groupPath, repoName = params["group_path"].([]string), params["repo_name"].(string)
-
- if err := s.database.QueryRow(request.Context(), `
- WITH RECURSIVE group_path_cte AS (
- -- Start: match the first name in the path where parent_group IS NULL
- SELECT
- id,
- parent_group,
- name,
- 1 AS depth
- FROM groups
- WHERE name = ($1::text[])[1]
- AND parent_group IS NULL
-
- UNION ALL
-
- -- Recurse: jion next segment of the path
- SELECT
- g.id,
- g.parent_group,
- g.name,
- group_path_cte.depth + 1
- FROM groups g
- JOIN group_path_cte ON g.parent_group = group_path_cte.id
- WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
- AND group_path_cte.depth + 1 <= cardinality($1::text[])
- )
- SELECT r.filesystem_path
- FROM group_path_cte c
- JOIN repos r ON r.group_id = c.id
- WHERE c.depth = cardinality($1::text[])
- AND r.name = $2
- `,
- pgtype.FlatArray[string](groupPath),
- repoName,
- ).Scan(&repoPath); err != nil {
- return err
- }
-
- writer.Header().Set("Content-Type", "application/x-git-upload-pack-result")
- // writer.Header().Set("Connection", "Keep-Alive")
- // writer.Header().Set("Transfer-Encoding", "chunked")
-
- cmd = exec.CommandContext(request.Context(), "git", "upload-pack", "--stateless-rpc", repoPath)
- cmd.Env = append(os.Environ(), "LINDENII_FORGE_HOOKS_SOCKET_PATH="+s.config.Hooks.Socket)
-
- var stderrBuf bytes.Buffer
- cmd.Stderr = &stderrBuf
-
- cmd.Stdout = writer
- cmd.Stdin = decoded
-
- if gp := request.Header.Get("Git-Protocol"); gp != "" {
- cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+gp)
- }
-
- if err = cmd.Run(); err != nil {
- log.Println(stderrBuf.String())
- return err
- }
-
- return nil
-}
-
-func decodeBody(r *http.Request) (io.ReadCloser, error) {
- switch ce := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Encoding"))); ce {
- case "", "identity":
- return r.Body, nil
- case "gzip":
- zr, err := gzip.NewReader(r.Body)
- if err != nil { return nil, err }
- return zr, nil
- case "deflate":
- zr, err := zlib.NewReader(r.Body)
- if err != nil { return nil, err }
- return zr, nil
- default:
- return nil, fmt.Errorf("unsupported Content-Encoding: %q", ce)
- }
-}
diff --git a/forged/internal/unsorted/http_handle_users.go b/forged/internal/unsorted/http_handle_users.go
deleted file mode 100644
index b41ee44..0000000
--- a/forged/internal/unsorted/http_handle_users.go
+++ /dev/null
@@ -1,15 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/http"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// httpHandleUsers is a useless stub.
-func (s *Server) httpHandleUsers(writer http.ResponseWriter, _ *http.Request, params map[string]any) {
- web.ErrorPage501(s.templates, writer, params)
-}
diff --git a/forged/internal/unsorted/http_server.go b/forged/internal/unsorted/http_server.go
deleted file mode 100644
index f6a1794..0000000
--- a/forged/internal/unsorted/http_server.go
+++ /dev/null
@@ -1,276 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "errors"
- "log/slog"
- "net/http"
- "net/url"
- "strconv"
- "strings"
-
- "github.com/jackc/pgx/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- "go.lindenii.runxiyu.org/forge/forged/internal/web"
-)
-
-// ServeHTTP handles all incoming HTTP requests and routes them to the correct
-// location.
-//
-// TODO: This function is way too large.
-func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
- var remoteAddr string
- if s.config.HTTP.ReverseProxy {
- remoteAddrs, ok := request.Header["X-Forwarded-For"]
- if ok && len(remoteAddrs) == 1 {
- remoteAddr = remoteAddrs[0]
- } else {
- remoteAddr = request.RemoteAddr
- }
- } else {
- remoteAddr = request.RemoteAddr
- }
- slog.Info("incoming http", "addr", remoteAddr, "method", request.Method, "uri", request.RequestURI)
-
- var segments []string
- var err error
- var sepIndex int
- params := make(map[string]any)
-
- if segments, _, err = misc.ParseReqURI(request.RequestURI); err != nil {
- web.ErrorPage400(s.templates, writer, params, "Error parsing request URI: "+err.Error())
- return
- }
- dirMode := false
- if segments[len(segments)-1] == "" {
- dirMode = true
- segments = segments[:len(segments)-1]
- }
-
- params["url_segments"] = segments
- params["dir_mode"] = dirMode
- params["global"] = s.globalData
- var userID int // 0 for none
- userID, params["username"], err = s.getUserFromRequest(request)
- params["user_id"] = userID
- if err != nil && !errors.Is(err, http.ErrNoCookie) && !errors.Is(err, pgx.ErrNoRows) {
- web.ErrorPage500(s.templates, writer, params, "Error getting user info from request: "+err.Error())
- return
- }
-
- if userID == 0 {
- params["user_id_string"] = ""
- } else {
- params["user_id_string"] = strconv.Itoa(userID)
- }
-
- for _, v := range segments {
- if strings.Contains(v, ":") {
- web.ErrorPage400Colon(s.templates, writer, params)
- return
- }
- }
-
- if len(segments) == 0 {
- s.httpHandleIndex(writer, request, params)
- return
- }
-
- if segments[0] == "-" {
- if len(segments) < 2 {
- web.ErrorPage404(s.templates, writer, params)
- return
- } else if len(segments) == 2 && misc.RedirectDir(writer, request) {
- return
- }
-
- switch segments[1] {
- case "static":
- s.staticHandler.ServeHTTP(writer, request)
- return
- case "source":
- s.sourceHandler.ServeHTTP(writer, request)
- return
- }
- }
-
- if segments[0] == "-" {
- switch segments[1] {
- case "login":
- s.httpHandleLogin(writer, request, params)
- return
- case "users":
- s.httpHandleUsers(writer, request, params)
- return
- default:
- web.ErrorPage404(s.templates, writer, params)
- return
- }
- }
-
- sepIndex = -1
- for i, part := range segments {
- if part == "-" {
- sepIndex = i
- break
- }
- }
-
- params["separator_index"] = sepIndex
-
- var groupPath []string
- var moduleType string
- var moduleName string
-
- if sepIndex > 0 {
- groupPath = segments[:sepIndex]
- } else {
- groupPath = segments
- }
- params["group_path"] = groupPath
-
- switch {
- case sepIndex == -1:
- if misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleGroupIndex(writer, request, params)
- case len(segments) == sepIndex+1:
- web.ErrorPage404(s.templates, writer, params)
- return
- case len(segments) == sepIndex+2:
- web.ErrorPage404(s.templates, writer, params)
- return
- default:
- moduleType = segments[sepIndex+1]
- moduleName = segments[sepIndex+2]
- switch moduleType {
- case "repos":
- params["repo_name"] = moduleName
-
- if len(segments) > sepIndex+3 {
- switch segments[sepIndex+3] {
- case "info":
- if err = s.httpHandleRepoInfo(writer, request, params); err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- }
- return
- case "git-upload-pack":
- if err = s.httpHandleUploadPack(writer, request, params); err != nil {
- web.ErrorPage500(s.templates, writer, params, err.Error())
- }
- return
- }
- }
-
- if params["ref_type"], params["ref_name"], err = misc.GetParamRefTypeName(request); err != nil {
- if errors.Is(err, misc.ErrNoRefSpec) {
- params["ref_type"] = ""
- } else {
- web.ErrorPage400(s.templates, writer, params, "Error querying ref type: "+err.Error())
- return
- }
- }
-
- if params["repo"], params["repo_description"], params["repo_id"], _, err = s.openRepo(request.Context(), groupPath, moduleName); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error opening repo: "+err.Error())
- return
- }
-
- repoURLRoot := "/"
- for _, part := range segments[:sepIndex+3] {
- repoURLRoot = repoURLRoot + url.PathEscape(part) + "/"
- }
- params["repo_url_root"] = repoURLRoot
- params["repo_patch_mailing_list"] = repoURLRoot[1:len(repoURLRoot)-1] + "@" + s.config.LMTP.Domain
- params["http_clone_url"] = s.genHTTPRemoteURL(groupPath, moduleName)
- params["ssh_clone_url"] = s.genSSHRemoteURL(groupPath, moduleName)
-
- if len(segments) == sepIndex+3 {
- if misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleRepoIndex(writer, request, params)
- return
- }
-
- repoFeature := segments[sepIndex+3]
- switch repoFeature {
- case "tree":
- if misc.AnyContain(segments[sepIndex+4:], "/") {
- web.ErrorPage400(s.templates, writer, params, "Repo tree paths may not contain slashes in any segments")
- return
- }
- if dirMode {
- params["rest"] = strings.Join(segments[sepIndex+4:], "/") + "/"
- } else {
- params["rest"] = strings.Join(segments[sepIndex+4:], "/")
- }
- if len(segments) < sepIndex+5 && misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleRepoTree(writer, request, params)
- case "branches":
- if misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleRepoBranches(writer, request, params)
- return
- case "raw":
- if misc.AnyContain(segments[sepIndex+4:], "/") {
- web.ErrorPage400(s.templates, writer, params, "Repo tree paths may not contain slashes in any segments")
- return
- }
- if dirMode {
- params["rest"] = strings.Join(segments[sepIndex+4:], "/") + "/"
- } else {
- params["rest"] = strings.Join(segments[sepIndex+4:], "/")
- }
- if len(segments) < sepIndex+5 && misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleRepoRaw(writer, request, params)
- case "log":
- if len(segments) > sepIndex+4 {
- web.ErrorPage400(s.templates, writer, params, "Too many parameters")
- return
- }
- if misc.RedirectDir(writer, request) {
- return
- }
- s.httpHandleRepoLog(writer, request, params)
- case "commit":
- if len(segments) != sepIndex+5 {
- web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
- return
- }
- if misc.RedirectNoDir(writer, request) {
- return
- }
- params["commit_id"] = segments[sepIndex+4]
- s.httpHandleRepoCommit(writer, request, params)
- case "contrib":
- if misc.RedirectDir(writer, request) {
- return
- }
- switch len(segments) {
- case sepIndex + 4:
- s.httpHandleRepoContribIndex(writer, request, params)
- case sepIndex + 5:
- params["mr_id"] = segments[sepIndex+4]
- s.httpHandleRepoContribOne(writer, request, params)
- default:
- web.ErrorPage400(s.templates, writer, params, "Too many parameters")
- }
- default:
- web.ErrorPage404(s.templates, writer, params)
- return
- }
- default:
- web.ErrorPage404(s.templates, writer, params)
- return
- }
- }
-}
diff --git a/forged/internal/unsorted/http_template.go b/forged/internal/unsorted/http_template.go
deleted file mode 100644
index db44e4c..0000000
--- a/forged/internal/unsorted/http_template.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "log/slog"
- "net/http"
-)
-
-// renderTemplate abstracts out the annoyances of reporting template rendering
-// errors.
-func (s *Server) renderTemplate(w http.ResponseWriter, templateName string, params map[string]any) {
- if err := s.templates.ExecuteTemplate(w, templateName, params); err != nil {
- http.Error(w, "error rendering template: "+err.Error(), http.StatusInternalServerError)
- slog.Error("error rendering template", "error", err.Error())
- }
-}
diff --git a/forged/internal/unsorted/lmtp_handle_patch.go b/forged/internal/unsorted/lmtp_handle_patch.go
deleted file mode 100644
index b258bfc..0000000
--- a/forged/internal/unsorted/lmtp_handle_patch.go
+++ /dev/null
@@ -1,133 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "bytes"
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "io"
- "os"
- "os/exec"
- "strings"
- "time"
-
- "github.com/bluekeyes/go-gitdiff/gitdiff"
- "github.com/go-git/go-git/v5"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-func (s *Server) lmtpHandlePatch(session *lmtpSession, groupPath []string, repoName string, mbox io.Reader) (err error) {
- var diffFiles []*gitdiff.File
- var preamble string
- if diffFiles, preamble, err = gitdiff.Parse(mbox); err != nil {
- return fmt.Errorf("failed to parse patch: %w", err)
- }
-
- var header *gitdiff.PatchHeader
- if header, err = gitdiff.ParsePatchHeader(preamble); err != nil {
- return fmt.Errorf("failed to parse patch headers: %w", err)
- }
-
- var repo *git.Repository
- var fsPath string
- repo, _, _, fsPath, err = s.openRepo(session.ctx, groupPath, repoName)
- if err != nil {
- return fmt.Errorf("failed to open repo: %w", err)
- }
-
- headRef, err := repo.Head()
- if err != nil {
- return fmt.Errorf("failed to get repo head hash: %w", err)
- }
- headCommit, err := repo.CommitObject(headRef.Hash())
- if err != nil {
- return fmt.Errorf("failed to get repo head commit: %w", err)
- }
- headTree, err := headCommit.Tree()
- if err != nil {
- return fmt.Errorf("failed to get repo head tree: %w", err)
- }
-
- headTreeHash := headTree.Hash.String()
-
- blobUpdates := make(map[string][]byte)
- for _, diffFile := range diffFiles {
- sourceFile, err := headTree.File(diffFile.OldName)
- if err != nil {
- return fmt.Errorf("failed to get file at old name %#v: %w", diffFile.OldName, err)
- }
- sourceString, err := sourceFile.Contents()
- if err != nil {
- return fmt.Errorf("failed to get contents: %w", err)
- }
-
- sourceBuf := bytes.NewReader(misc.StringToBytes(sourceString))
- var patchedBuf bytes.Buffer
- if err := gitdiff.Apply(&patchedBuf, sourceBuf, diffFile); err != nil {
- return fmt.Errorf("failed to apply patch: %w", err)
- }
-
- var hashBuf bytes.Buffer
-
- // It's really difficult to do this via go-git so we're just
- // going to use upstream git for now.
- // TODO
- cmd := exec.CommandContext(session.ctx, "git", "hash-object", "-w", "-t", "blob", "--stdin")
- cmd.Env = append(os.Environ(), "GIT_DIR="+fsPath)
- cmd.Stdout = &hashBuf
- cmd.Stdin = &patchedBuf
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("failed to run git hash-object: %w", err)
- }
-
- newHashStr := strings.TrimSpace(hashBuf.String())
- newHash, err := hex.DecodeString(newHashStr)
- if err != nil {
- return fmt.Errorf("failed to decode hex string from git: %w", err)
- }
-
- blobUpdates[diffFile.NewName] = newHash
- if diffFile.NewName != diffFile.OldName {
- blobUpdates[diffFile.OldName] = nil // Mark for deletion.
- }
- }
-
- newTreeSha, err := buildTreeRecursive(session.ctx, fsPath, headTreeHash, blobUpdates)
- if err != nil {
- return fmt.Errorf("failed to recursively build a tree: %w", err)
- }
-
- commitMsg := header.Title
- if header.Body != "" {
- commitMsg += "\n\n" + header.Body
- }
-
- env := append(os.Environ(),
- "GIT_DIR="+fsPath,
- "GIT_AUTHOR_NAME="+header.Author.Name,
- "GIT_AUTHOR_EMAIL="+header.Author.Email,
- "GIT_AUTHOR_DATE="+header.AuthorDate.Format(time.RFC3339),
- )
- commitCmd := exec.CommandContext(session.ctx, "git", "commit-tree", newTreeSha, "-p", headCommit.Hash.String(), "-m", commitMsg)
- commitCmd.Env = env
-
- var commitOut bytes.Buffer
- commitCmd.Stdout = &commitOut
- if err := commitCmd.Run(); err != nil {
- return fmt.Errorf("failed to commit tree: %w", err)
- }
- newCommitSha := strings.TrimSpace(commitOut.String())
-
- newBranchName := rand.Text()
-
- refCmd := exec.CommandContext(session.ctx, "git", "update-ref", "refs/heads/contrib/"+newBranchName, newCommitSha) //#nosec G204
- refCmd.Env = append(os.Environ(), "GIT_DIR="+fsPath)
- if err := refCmd.Run(); err != nil {
- return fmt.Errorf("failed to update ref: %w", err)
- }
-
- return nil
-}
diff --git a/forged/internal/unsorted/lmtp_server.go b/forged/internal/unsorted/lmtp_server.go
deleted file mode 100644
index 1e94894..0000000
--- a/forged/internal/unsorted/lmtp_server.go
+++ /dev/null
@@ -1,204 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-// SPDX-FileCopyrightText: Copyright (c) 2024 Robin Jarry <robin@jarry.cc>
-
-package unsorted
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net"
- "strings"
- "time"
-
- "github.com/emersion/go-message"
- "github.com/emersion/go-smtp"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-type lmtpHandler struct{}
-
-type lmtpSession struct {
- from string
- to []string
- ctx context.Context
- cancel context.CancelFunc
- s Server
-}
-
-func (session *lmtpSession) Reset() {
- session.from = ""
- session.to = nil
-}
-
-func (session *lmtpSession) Logout() error {
- session.cancel()
- return nil
-}
-
-func (session *lmtpSession) AuthPlain(_, _ string) error {
- return nil
-}
-
-func (session *lmtpSession) Mail(from string, _ *smtp.MailOptions) error {
- session.from = from
- return nil
-}
-
-func (session *lmtpSession) Rcpt(to string, _ *smtp.RcptOptions) error {
- session.to = append(session.to, to)
- return nil
-}
-
-func (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
- ctx, cancel := context.WithCancel(context.Background())
- session := &lmtpSession{
- ctx: ctx,
- cancel: cancel,
- }
- return session, nil
-}
-
-func (s *Server) serveLMTP(listener net.Listener) error {
- smtpServer := smtp.NewServer(&lmtpHandler{})
- smtpServer.LMTP = true
- smtpServer.Domain = s.config.LMTP.Domain
- smtpServer.Addr = s.config.LMTP.Socket
- smtpServer.WriteTimeout = time.Duration(s.config.LMTP.WriteTimeout) * time.Second
- smtpServer.ReadTimeout = time.Duration(s.config.LMTP.ReadTimeout) * time.Second
- smtpServer.EnableSMTPUTF8 = true
- return smtpServer.Serve(listener)
-}
-
-func (session *lmtpSession) Data(r io.Reader) error {
- var (
- email *message.Entity
- from string
- to []string
- err error
- buf bytes.Buffer
- data []byte
- n int64
- )
-
- n, err = io.CopyN(&buf, r, session.s.config.LMTP.MaxSize)
- switch {
- case n == session.s.config.LMTP.MaxSize:
- err = errors.New("Message too big.")
- // drain whatever is left in the pipe
- _, _ = io.Copy(io.Discard, r)
- goto end
- case errors.Is(err, io.EOF):
- // message was smaller than max size
- break
- case err != nil:
- goto end
- }
-
- data = buf.Bytes()
-
- email, err = message.Read(bytes.NewReader(data))
- if err != nil && message.IsUnknownCharset(err) {
- goto end
- }
-
- switch strings.ToLower(email.Header.Get("Auto-Submitted")) {
- case "auto-generated", "auto-replied":
- // Disregard automatic emails like OOO replies
- slog.Info("ignoring automatic message",
- "from", session.from,
- "to", strings.Join(session.to, ","),
- "message-id", email.Header.Get("Message-Id"),
- "subject", email.Header.Get("Subject"),
- )
- goto end
- }
-
- slog.Info("message received",
- "from", session.from,
- "to", strings.Join(session.to, ","),
- "message-id", email.Header.Get("Message-Id"),
- "subject", email.Header.Get("Subject"),
- )
-
- // Make local copies of the values before to ensure the references will
- // still be valid when the task is run.
- from = session.from
- to = session.to
-
- _ = from
-
- for _, to := range to {
- if !strings.HasSuffix(to, "@"+session.s.config.LMTP.Domain) {
- continue
- }
- localPart := to[:len(to)-len("@"+session.s.config.LMTP.Domain)]
- var segments []string
- segments, err = misc.PathToSegments(localPart)
- if err != nil {
- // TODO: Should the entire email fail or should we just
- // notify them out of band?
- err = fmt.Errorf("cannot parse path: %w", err)
- goto end
- }
- sepIndex := -1
- for i, part := range segments {
- if part == "-" {
- sepIndex = i
- break
- }
- }
- if segments[len(segments)-1] == "" {
- segments = segments[:len(segments)-1] // We don't care about dir or not.
- }
- if sepIndex == -1 || len(segments) <= sepIndex+2 {
- err = errors.New("illegal path")
- goto end
- }
-
- mbox := bytes.Buffer{}
- if _, err = fmt.Fprint(&mbox, "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"); err != nil {
- slog.Error("error handling patch... malloc???", "error", err)
- goto end
- }
- data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
- if _, err = mbox.Write(data); err != nil {
- slog.Error("error handling patch... malloc???", "error", err)
- goto end
- }
- // TODO: Is mbox's From escaping necessary here?
-
- groupPath := segments[:sepIndex]
- moduleType := segments[sepIndex+1]
- moduleName := segments[sepIndex+2]
- switch moduleType {
- case "repos":
- err = session.s.lmtpHandlePatch(session, groupPath, moduleName, &mbox)
- if err != nil {
- slog.Error("error handling patch", "error", err)
- goto end
- }
- default:
- err = errors.New("Emailing any endpoint other than repositories, is not supported yet.") // TODO
- goto end
- }
- }
-
-end:
- session.to = nil
- session.from = ""
- switch err {
- case nil:
- return nil
- default:
- return &smtp.SMTPError{
- Code: 550,
- Message: "Permanent failure: " + err.Error(),
- EnhancedCode: [3]int{5, 7, 1},
- }
- }
-}
diff --git a/forged/internal/unsorted/remote_url.go b/forged/internal/unsorted/remote_url.go
deleted file mode 100644
index f4d4c58..0000000
--- a/forged/internal/unsorted/remote_url.go
+++ /dev/null
@@ -1,25 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "net/url"
- "strings"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-// We don't use path.Join because it collapses multiple slashes into one.
-
-// genSSHRemoteURL generates SSH remote URLs from a given group path and repo
-// name.
-func (s *Server) genSSHRemoteURL(groupPath []string, repoName string) string {
- return strings.TrimSuffix(s.config.SSH.Root, "/") + "/" + misc.SegmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
-}
-
-// genHTTPRemoteURL generates HTTP remote URLs from a given group path and repo
-// name.
-func (s *Server) genHTTPRemoteURL(groupPath []string, repoName string) string {
- return strings.TrimSuffix(s.config.HTTP.Root, "/") + "/" + misc.SegmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
-}
diff --git a/forged/internal/unsorted/resources.go b/forged/internal/unsorted/resources.go
deleted file mode 100644
index 692b454..0000000
--- a/forged/internal/unsorted/resources.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "html/template"
- "io/fs"
-
- "github.com/tdewolff/minify/v2"
- "github.com/tdewolff/minify/v2/html"
- "go.lindenii.runxiyu.org/forge/forged/internal/embed"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-// loadTemplates minifies and loads HTML templates.
-func (s *Server) loadTemplates() (err error) {
- minifier := minify.New()
- minifierOptions := html.Minifier{
- TemplateDelims: [2]string{"{{", "}}"},
- KeepDefaultAttrVals: true,
- } //exhaustruct:ignore
- minifier.Add("text/html", &minifierOptions)
-
- s.templates = template.New("templates").Funcs(template.FuncMap{
- "first_line": misc.FirstLine,
- "path_escape": misc.PathEscape,
- "query_escape": misc.QueryEscape,
- "dereference_error": misc.DereferenceOrZero[error],
- "minus": misc.Minus,
- })
-
- err = fs.WalkDir(embed.Resources, "forged/templates", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if !d.IsDir() {
- content, err := fs.ReadFile(embed.Resources, path)
- if err != nil {
- return err
- }
-
- minified, err := minifier.Bytes("text/html", content)
- if err != nil {
- return err
- }
-
- _, err = s.templates.Parse(misc.BytesToString(minified))
- if err != nil {
- return err
- }
- }
- return nil
- })
- return err
-}
diff --git a/forged/internal/unsorted/server.go b/forged/internal/unsorted/server.go
deleted file mode 100644
index 84379b0..0000000
--- a/forged/internal/unsorted/server.go
+++ /dev/null
@@ -1,236 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "errors"
- "html/template"
- "io/fs"
- "log"
- "log/slog"
- "net"
- "net/http"
- _ "net/http/pprof"
- "os"
- "os/exec"
- "path/filepath"
- "syscall"
- "time"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/cmap"
- "go.lindenii.runxiyu.org/forge/forged/internal/database"
- "go.lindenii.runxiyu.org/forge/forged/internal/embed"
- "go.lindenii.runxiyu.org/forge/forged/internal/irc"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- goSSH "golang.org/x/crypto/ssh"
-)
-
-type Server struct {
- config Config
-
- database database.Database
-
- sourceHandler http.Handler
- staticHandler http.Handler
-
- // globalData is passed as "global" when rendering HTML templates.
- globalData map[string]any
-
- serverPubkeyString string
- serverPubkeyFP string
- serverPubkey goSSH.PublicKey
-
- // packPasses contains hook cookies mapped to their packPass.
- packPasses cmap.Map[string, packPass]
-
- templates *template.Template
-
- ircBot *irc.Bot
-
- ready bool
-}
-
-func NewServer(configPath string) (*Server, error) {
- s := &Server{
- globalData: make(map[string]any),
- } //exhaustruct:ignore
-
- s.sourceHandler = http.StripPrefix(
- "/-/source/",
- http.FileServer(http.FS(embed.Source)),
- )
- staticFS, err := fs.Sub(embed.Resources, "forged/static")
- if err != nil {
- return s, err
- }
- s.staticHandler = http.StripPrefix("/-/static/", http.FileServer(http.FS(staticFS)))
- s.globalData = map[string]any{
- "server_public_key_string": &s.serverPubkeyString,
- "server_public_key_fingerprint": &s.serverPubkeyFP,
- "forge_version": version,
- // Some other ones are populated after config parsing
- }
-
- if err := s.loadConfig(configPath); err != nil {
- return s, err
- }
-
- misc.NoneOrPanic(s.loadTemplates())
- misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embed.Resources.Open("git2d/git2d")), s.config.Git.DaemonPath))
- misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embed.Resources.Open("hookc/hookc")), filepath.Join(s.config.Hooks.Execs, "pre-receive")))
- misc.NoneOrPanic(os.Chmod(filepath.Join(s.config.Hooks.Execs, "pre-receive"), 0o755))
-
- s.ready = true
-
- return s, nil
-}
-
-func (s *Server) Run() error {
- if !s.ready {
- return errors.New("not ready")
- }
-
- // Launch Git2D
- go func() {
- cmd := exec.Command(s.config.Git.DaemonPath, s.config.Git.Socket) //#nosec G204
- cmd.Stderr = log.Writer()
- cmd.Stdout = log.Writer()
- if err := cmd.Run(); err != nil {
- panic(err)
- }
- }()
-
- // UNIX socket listener for hooks
- {
- hooksListener, err := net.Listen("unix", s.config.Hooks.Socket)
- if errors.Is(err, syscall.EADDRINUSE) {
- slog.Warn("removing existing socket", "path", s.config.Hooks.Socket)
- if err = syscall.Unlink(s.config.Hooks.Socket); err != nil {
- slog.Error("removing existing socket", "path", s.config.Hooks.Socket, "error", err)
- os.Exit(1)
- }
- if hooksListener, err = net.Listen("unix", s.config.Hooks.Socket); err != nil {
- slog.Error("listening hooks", "error", err)
- os.Exit(1)
- }
- } else if err != nil {
- slog.Error("listening hooks", "error", err)
- os.Exit(1)
- }
- slog.Info("listening hooks on unix", "path", s.config.Hooks.Socket)
- go func() {
- if err = s.serveGitHooks(hooksListener); err != nil {
- slog.Error("serving hooks", "error", err)
- os.Exit(1)
- }
- }()
- }
-
- // UNIX socket listener for LMTP
- {
- lmtpListener, err := net.Listen("unix", s.config.LMTP.Socket)
- if errors.Is(err, syscall.EADDRINUSE) {
- slog.Warn("removing existing socket", "path", s.config.LMTP.Socket)
- if err = syscall.Unlink(s.config.LMTP.Socket); err != nil {
- slog.Error("removing existing socket", "path", s.config.LMTP.Socket, "error", err)
- os.Exit(1)
- }
- if lmtpListener, err = net.Listen("unix", s.config.LMTP.Socket); err != nil {
- slog.Error("listening LMTP", "error", err)
- os.Exit(1)
- }
- } else if err != nil {
- slog.Error("listening LMTP", "error", err)
- os.Exit(1)
- }
- slog.Info("listening LMTP on unix", "path", s.config.LMTP.Socket)
- go func() {
- if err = s.serveLMTP(lmtpListener); err != nil {
- slog.Error("serving LMTP", "error", err)
- os.Exit(1)
- }
- }()
- }
-
- // SSH listener
- {
- sshListener, err := net.Listen(s.config.SSH.Net, s.config.SSH.Addr)
- if errors.Is(err, syscall.EADDRINUSE) && s.config.SSH.Net == "unix" {
- slog.Warn("removing existing socket", "path", s.config.SSH.Addr)
- if err = syscall.Unlink(s.config.SSH.Addr); err != nil {
- slog.Error("removing existing socket", "path", s.config.SSH.Addr, "error", err)
- os.Exit(1)
- }
- if sshListener, err = net.Listen(s.config.SSH.Net, s.config.SSH.Addr); err != nil {
- slog.Error("listening SSH", "error", err)
- os.Exit(1)
- }
- } else if err != nil {
- slog.Error("listening SSH", "error", err)
- os.Exit(1)
- }
- slog.Info("listening SSH on", "net", s.config.SSH.Net, "addr", s.config.SSH.Addr)
- go func() {
- if err = s.serveSSH(sshListener); err != nil {
- slog.Error("serving SSH", "error", err)
- os.Exit(1)
- }
- }()
- }
-
- // HTTP listener
- {
- httpListener, err := net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr)
- if errors.Is(err, syscall.EADDRINUSE) && s.config.HTTP.Net == "unix" {
- slog.Warn("removing existing socket", "path", s.config.HTTP.Addr)
- if err = syscall.Unlink(s.config.HTTP.Addr); err != nil {
- slog.Error("removing existing socket", "path", s.config.HTTP.Addr, "error", err)
- os.Exit(1)
- }
- if httpListener, err = net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr); err != nil {
- slog.Error("listening HTTP", "error", err)
- os.Exit(1)
- }
- } else if err != nil {
- slog.Error("listening HTTP", "error", err)
- os.Exit(1)
- }
- server := http.Server{
- Handler: s,
- ReadTimeout: time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
- WriteTimeout: time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
- IdleTimeout: time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
- } //exhaustruct:ignore
- slog.Info("listening HTTP on", "net", s.config.HTTP.Net, "addr", s.config.HTTP.Addr)
- go func() {
- if err = server.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
- slog.Error("serving HTTP", "error", err)
- os.Exit(1)
- }
- }()
- }
-
- // Pprof listener
- {
- pprofListener, err := net.Listen(s.config.Pprof.Net, s.config.Pprof.Addr)
- if err != nil {
- slog.Error("listening pprof", "error", err)
- os.Exit(1)
- }
-
- slog.Info("listening pprof on", "net", s.config.Pprof.Net, "addr", s.config.Pprof.Addr)
- go func() {
- if err := http.Serve(pprofListener, nil); err != nil {
- slog.Error("serving pprof", "error", err)
- os.Exit(1)
- }
- }()
- }
-
- s.ircBot = irc.NewBot(&s.config.IRC)
- // IRC bot
- go s.ircBot.ConnectLoop()
-
- select {}
-}
diff --git a/forged/internal/unsorted/ssh_handle_receive_pack.go b/forged/internal/unsorted/ssh_handle_receive_pack.go
deleted file mode 100644
index a354273..0000000
--- a/forged/internal/unsorted/ssh_handle_receive_pack.go
+++ /dev/null
@@ -1,131 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "errors"
- "fmt"
- "os"
- "os/exec"
-
- gliderSSH "github.com/gliderlabs/ssh"
- "github.com/go-git/go-git/v5"
-)
-
-// packPass contains information known when handling incoming SSH connections
-// that then needs to be used in hook socket connection handlers. See hookc(1).
-type packPass struct {
- session gliderSSH.Session
- repo *git.Repository
- pubkey string
- directAccess bool
- repoPath string
- userID int
- userType string
- repoID int
- groupPath []string
- repoName string
- contribReq string
-}
-
-// sshHandleRecvPack handles attempts to push to repos.
-func (s *Server) sshHandleRecvPack(session gliderSSH.Session, pubkey, repoIdentifier string) (err error) {
- groupPath, repoName, repoID, repoPath, directAccess, contribReq, userType, userID, err := s.getRepoInfo2(session.Context(), repoIdentifier, pubkey)
- if err != nil {
- return err
- }
- repo, err := git.PlainOpen(repoPath)
- if err != nil {
- return err
- }
-
- repoConf, err := repo.Config()
- if err != nil {
- return err
- }
-
- repoConfCore := repoConf.Raw.Section("core")
- if repoConfCore == nil {
- return errors.New("repository has no core section in config")
- }
-
- hooksPath := repoConfCore.OptionAll("hooksPath")
- if len(hooksPath) != 1 || hooksPath[0] != s.config.Hooks.Execs {
- return errors.New("repository has hooksPath set to an unexpected value")
- }
-
- if !directAccess {
- switch contribReq {
- case "closed":
- if !directAccess {
- return errors.New("you need direct access to push to this repo")
- }
- case "registered_user":
- if userType != "registered" {
- return errors.New("you need to be a registered user to push to this repo")
- }
- case "ssh_pubkey":
- fallthrough
- case "federated":
- if pubkey == "" {
- return errors.New("you need to have an SSH public key to push to this repo")
- }
- if userType == "" {
- userID, err = s.addUserSSH(session.Context(), pubkey)
- if err != nil {
- return err
- }
- fmt.Fprintln(session.Stderr(), "you are now registered as user ID", userID)
- userType = "pubkey_only"
- }
-
- case "public":
- default:
- panic("unknown contrib_requirements value " + contribReq)
- }
- }
-
- cookie, err := randomUrlsafeStr(16)
- if err != nil {
- fmt.Fprintln(session.Stderr(), "Error while generating cookie:", err)
- }
-
- s.packPasses.Store(cookie, packPass{
- session: session,
- pubkey: pubkey,
- directAccess: directAccess,
- repoPath: repoPath,
- userID: userID,
- repoID: repoID,
- groupPath: groupPath,
- repoName: repoName,
- repo: repo,
- contribReq: contribReq,
- userType: userType,
- })
- defer s.packPasses.Delete(cookie)
- // The Delete won't execute until proc.Wait returns unless something
- // horribly wrong such as a panic occurs.
-
- proc := exec.CommandContext(session.Context(), "git-receive-pack", repoPath)
- proc.Env = append(os.Environ(),
- "LINDENII_FORGE_HOOKS_SOCKET_PATH="+s.config.Hooks.Socket,
- "LINDENII_FORGE_HOOKS_COOKIE="+cookie,
- )
- proc.Stdin = session
- proc.Stdout = session
- proc.Stderr = session.Stderr()
-
- if err = proc.Start(); err != nil {
- fmt.Fprintln(session.Stderr(), "Error while starting process:", err)
- return err
- }
-
- err = proc.Wait()
- if err != nil {
- fmt.Fprintln(session.Stderr(), "Error while waiting for process:", err)
- }
-
- return err
-}
diff --git a/forged/internal/unsorted/ssh_handle_upload_pack.go b/forged/internal/unsorted/ssh_handle_upload_pack.go
deleted file mode 100644
index 735a053..0000000
--- a/forged/internal/unsorted/ssh_handle_upload_pack.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "fmt"
- "os"
- "os/exec"
-
- glider_ssh "github.com/gliderlabs/ssh"
-)
-
-// sshHandleUploadPack handles clones/fetches. It just uses git-upload-pack
-// and has no ACL checks.
-func (s *Server) sshHandleUploadPack(session glider_ssh.Session, pubkey, repoIdentifier string) (err error) {
- var repoPath string
- if _, _, _, repoPath, _, _, _, _, err = s.getRepoInfo2(session.Context(), repoIdentifier, pubkey); err != nil {
- return err
- }
-
- proc := exec.CommandContext(session.Context(), "git-upload-pack", repoPath)
- proc.Env = append(os.Environ(), "LINDENII_FORGE_HOOKS_SOCKET_PATH="+s.config.Hooks.Socket)
- proc.Stdin = session
- proc.Stdout = session
- proc.Stderr = session.Stderr()
-
- if err = proc.Start(); err != nil {
- fmt.Fprintln(session.Stderr(), "Error while starting process:", err)
- return err
- }
-
- err = proc.Wait()
- if err != nil {
- fmt.Fprintln(session.Stderr(), "Error while waiting for process:", err)
- }
-
- return err
-}
diff --git a/forged/internal/unsorted/ssh_server.go b/forged/internal/unsorted/ssh_server.go
deleted file mode 100644
index 43cc0c4..0000000
--- a/forged/internal/unsorted/ssh_server.go
+++ /dev/null
@@ -1,96 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "fmt"
- "log/slog"
- "net"
- "os"
- "strings"
-
- gliderSSH "github.com/gliderlabs/ssh"
- "go.lindenii.runxiyu.org/forge/forged/internal/ansiec"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
- goSSH "golang.org/x/crypto/ssh"
-)
-
-// serveSSH serves SSH on a [net.Listener]. The listener should generally be a
-// TCP listener, although AF_UNIX SOCK_STREAM listeners may be appropriate in
-// rare cases.
-func (s *Server) serveSSH(listener net.Listener) error {
- var hostKeyBytes []byte
- var hostKey goSSH.Signer
- var err error
- var server *gliderSSH.Server
-
- if hostKeyBytes, err = os.ReadFile(s.config.SSH.Key); err != nil {
- return err
- }
-
- if hostKey, err = goSSH.ParsePrivateKey(hostKeyBytes); err != nil {
- return err
- }
-
- s.serverPubkey = hostKey.PublicKey()
- s.serverPubkeyString = misc.BytesToString(goSSH.MarshalAuthorizedKey(s.serverPubkey))
- s.serverPubkeyFP = goSSH.FingerprintSHA256(s.serverPubkey)
-
- server = &gliderSSH.Server{
- Handler: func(session gliderSSH.Session) {
- clientPubkey := session.PublicKey()
- var clientPubkeyStr string
- if clientPubkey != nil {
- clientPubkeyStr = strings.TrimSuffix(misc.BytesToString(goSSH.MarshalAuthorizedKey(clientPubkey)), "\n")
- }
-
- slog.Info("incoming ssh", "addr", session.RemoteAddr().String(), "key", clientPubkeyStr, "command", session.RawCommand())
- fmt.Fprintln(session.Stderr(), ansiec.Blue+"Lindenii Forge "+version+", source at "+strings.TrimSuffix(s.config.HTTP.Root, "/")+"/-/source/"+ansiec.Reset+"\r")
-
- cmd := session.Command()
-
- if len(cmd) < 2 {
- fmt.Fprintln(session.Stderr(), "Insufficient arguments\r")
- return
- }
-
- switch cmd[0] {
- case "git-upload-pack":
- if len(cmd) > 2 {
- fmt.Fprintln(session.Stderr(), "Too many arguments\r")
- return
- }
- err = s.sshHandleUploadPack(session, clientPubkeyStr, cmd[1])
- case "git-receive-pack":
- if len(cmd) > 2 {
- fmt.Fprintln(session.Stderr(), "Too many arguments\r")
- return
- }
- err = s.sshHandleRecvPack(session, clientPubkeyStr, cmd[1])
- default:
- fmt.Fprintln(session.Stderr(), "Unsupported command: "+cmd[0]+"\r")
- return
- }
- if err != nil {
- fmt.Fprintln(session.Stderr(), err.Error())
- return
- }
- },
- PublicKeyHandler: func(_ gliderSSH.Context, _ gliderSSH.PublicKey) bool { return true },
- KeyboardInteractiveHandler: func(_ gliderSSH.Context, _ goSSH.KeyboardInteractiveChallenge) bool { return true },
- // It is intentional that we do not check any credentials and accept all connections.
- // This allows all users to connect and clone repositories. However, the public key
- // is passed to handlers, so e.g. the push handler could check the key and reject the
- // push if it needs to.
- } //exhaustruct:ignore
-
- server.AddHostKey(hostKey)
-
- if err = server.Serve(listener); err != nil {
- slog.Error("error serving SSH", "error", err.Error())
- os.Exit(1)
- }
-
- return nil
-}
diff --git a/forged/internal/unsorted/ssh_utils.go b/forged/internal/unsorted/ssh_utils.go
deleted file mode 100644
index 6f50a87..0000000
--- a/forged/internal/unsorted/ssh_utils.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/url"
-
- "go.lindenii.runxiyu.org/forge/forged/internal/ansiec"
- "go.lindenii.runxiyu.org/forge/forged/internal/misc"
-)
-
-var errIllegalSSHRepoPath = errors.New("illegal SSH repo path")
-
-// getRepoInfo2 also fetches repo information... it should be deprecated and
-// implemented in individual handlers.
-func (s *Server) getRepoInfo2(ctx context.Context, sshPath, sshPubkey string) (groupPath []string, repoName string, repoID int, repoPath string, directAccess bool, contribReq, userType string, userID int, err error) {
- var segments []string
- var sepIndex int
- var moduleType, moduleName string
-
- segments, err = misc.PathToSegments(sshPath)
- if err != nil {
- return
- }
-
- for i, segment := range segments {
- var err error
- segments[i], err = url.PathUnescape(segment)
- if err != nil {
- return []string{}, "", 0, "", false, "", "", 0, err
- }
- }
-
- if segments[0] == "-" {
- return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath
- }
-
- sepIndex = -1
- for i, part := range segments {
- if part == "-" {
- sepIndex = i
- break
- }
- }
- if segments[len(segments)-1] == "" {
- segments = segments[:len(segments)-1]
- }
-
- switch {
- case sepIndex == -1:
- return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath
- case len(segments) <= sepIndex+2:
- return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath
- }
-
- groupPath = segments[:sepIndex]
- moduleType = segments[sepIndex+1]
- moduleName = segments[sepIndex+2]
- repoName = moduleName
- switch moduleType {
- case "repos":
- _1, _2, _3, _4, _5, _6, _7 := s.getRepoInfo(ctx, groupPath, moduleName, sshPubkey)
- return groupPath, repoName, _1, _2, _3, _4, _5, _6, _7
- default:
- return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath
- }
-}
-
-// writeRedError is a helper function that basically does a Fprintf but makes
-// the entire thing red, in terms of ANSI escape sequences. It's useful when
-// producing error messages on SSH connections.
-func writeRedError(w io.Writer, format string, args ...any) {
- fmt.Fprintln(w, ansiec.Red+fmt.Sprintf(format, args...)+ansiec.Reset)
-}
diff --git a/forged/internal/unsorted/unsorted.go b/forged/internal/unsorted/unsorted.go
deleted file mode 100644
index f26b0e4..0000000
--- a/forged/internal/unsorted/unsorted.go
+++ /dev/null
@@ -1,5 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-// Package unsorted is where unsorted Go files from the old structure are kept.
-package unsorted
diff --git a/forged/internal/unsorted/users.go b/forged/internal/unsorted/users.go
deleted file mode 100644
index 0f72eed..0000000
--- a/forged/internal/unsorted/users.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package unsorted
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5"
-)
-
-// addUserSSH adds a new user solely based on their SSH public key.
-//
-// TODO: Audit all users of this function.
-func (s *Server) addUserSSH(ctx context.Context, pubkey string) (userID int, err error) {
- var txn pgx.Tx
-
- if txn, err = s.database.Begin(ctx); err != nil {
- return
- }
- defer func() {
- _ = txn.Rollback(ctx)
- }()
-
- if err = txn.QueryRow(ctx, `INSERT INTO users (type) VALUES ('pubkey_only') RETURNING id`).Scan(&userID); err != nil {
- return
- }
-
- if _, err = txn.Exec(ctx, `INSERT INTO ssh_public_keys (key_string, user_id) VALUES ($1, $2)`, pubkey, userID); err != nil {
- return
- }
-
- err = txn.Commit(ctx)
- return
-}
diff --git a/forged/internal/web/error_pages.go b/forged/internal/web/error_pages.go
deleted file mode 100644
index 2ba9a1a..0000000
--- a/forged/internal/web/error_pages.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-package web
-
-import (
- "html/template"
- "net/http"
-)
-
-// ErrorPage404 renders a 404 Not Found error page using the "404" template.
-func ErrorPage404(templates *template.Template, w http.ResponseWriter, params map[string]any) {
- w.WriteHeader(http.StatusNotFound)
- _ = templates.ExecuteTemplate(w, "404", params)
-}
-
-// ErrorPage400 renders a 400 Bad Request error page using the "400" template.
-// The error message is passed via the "complete_error_msg" template param.
-func ErrorPage400(templates *template.Template, w http.ResponseWriter, params map[string]any, msg string) {
- w.WriteHeader(http.StatusBadRequest)
- params["complete_error_msg"] = msg
- _ = templates.ExecuteTemplate(w, "400", params)
-}
-
-// ErrorPage400Colon renders a 400 Bad Request error page telling the user
-// that we migrated from : to -.
-func ErrorPage400Colon(templates *template.Template, w http.ResponseWriter, params map[string]any) {
- w.WriteHeader(http.StatusBadRequest)
- _ = templates.ExecuteTemplate(w, "400_colon", params)
-}
-
-// ErrorPage403 renders a 403 Forbidden error page using the "403" template.
-// The error message is passed via the "complete_error_msg" template param.
-func ErrorPage403(templates *template.Template, w http.ResponseWriter, params map[string]any, msg string) {
- w.WriteHeader(http.StatusForbidden)
- params["complete_error_msg"] = msg
- _ = templates.ExecuteTemplate(w, "403", params)
-}
-
-// ErrorPage451 renders a 451 Unavailable For Legal Reasons error page using the "451" template.
-// The error message is passed via the "complete_error_msg" template param.
-func ErrorPage451(templates *template.Template, w http.ResponseWriter, params map[string]any, msg string) {
- w.WriteHeader(http.StatusUnavailableForLegalReasons)
- params["complete_error_msg"] = msg
- _ = templates.ExecuteTemplate(w, "451", params)
-}
-
-// ErrorPage500 renders a 500 Internal Server Error page using the "500" template.
-// The error message is passed via the "complete_error_msg" template param.
-func ErrorPage500(templates *template.Template, w http.ResponseWriter, params map[string]any, msg string) {
- w.WriteHeader(http.StatusInternalServerError)
- params["complete_error_msg"] = msg
- _ = templates.ExecuteTemplate(w, "500", params)
-}
-
-// ErrorPage501 renders a 501 Not Implemented error page using the "501" template.
-func ErrorPage501(templates *template.Template, w http.ResponseWriter, params map[string]any) {
- w.WriteHeader(http.StatusNotImplemented)
- _ = templates.ExecuteTemplate(w, "501", params)
-}
diff --git a/forged/internal/web/web.go b/forged/internal/web/web.go
deleted file mode 100644
index f4d15f8..0000000
--- a/forged/internal/web/web.go
+++ /dev/null
@@ -1,5 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
-
-// Package web provides web-facing components of the forge.
-package web