Téléverser les fichiers vers "/"
This commit is contained in:
commit
ebd4775d5e
|
|
@ -0,0 +1,62 @@
|
|||
# PortaGit
|
||||
|
||||
PortaGit is a lightweight, self-hosted Git portfolio and repository viewer designed to showcase your work with a beautiful, modern interface. Built with Go, it offers a personal GitHub-like experience that you can host anywhere.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **👀 Repository Viewer**: Browse your files, commit history, and branches with a clean UI.
|
||||
- **📊 Contribution Graph**: Visualize your coding activity with a GitHub-style contribution calendar.
|
||||
- **🎨 Modern Design**: Sleek dark mode interface with responsive layout and smooth transitions.
|
||||
- **🛠️ Easy Setup**: Built-in setup wizard to configure your profile, bio, and social links.
|
||||
- **📝 Markdown Support**: Renders `README.md` files automatically.
|
||||
- **📁 File Management**: Create files, folders, and upload assets directly through the web interface.
|
||||
- **⚙️ Customizable**: personalize your profile, accent colors, and more.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Go](https://go.dev/dl/) 1.19 or higher
|
||||
- Git installed on your system
|
||||
|
||||
### Build from Source
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/PortaGit.git
|
||||
cd PortaGit
|
||||
```
|
||||
|
||||
2. Build the application:
|
||||
```bash
|
||||
go build -o portagit
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
```bash
|
||||
./portagit
|
||||
```
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:8080`.
|
||||
|
||||
## Configuration
|
||||
|
||||
On the first run, PortaGit will launch a **Setup Wizard** allowing you to:
|
||||
- Set your display name and bio.
|
||||
- Import data from your GitHub profile.
|
||||
- Configure themes and appearance.
|
||||
|
||||
The configuration is stored in a `portagit.json` file in the root directory.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `main.go`: Application entry point and HTTP server.
|
||||
- `git_utils.go`: Helper functions for interacting with Git repositories.
|
||||
- `web/`: Contains HTML templates and static assets (CSS, JS).
|
||||
- `repositories/`: Directory where your git repositories are stored (created automatically).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Feel free to open issues or submit pull requests.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
module portagit
|
||||
|
||||
go 1.25.3
|
||||
|
||||
require github.com/go-git/go-git/v5 v5.16.4
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,982 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var assets embed.FS
|
||||
|
||||
// ConfigFileName defines the name of the configuration file expected in the root directory.
|
||||
const configFileName = "portagit.json"
|
||||
|
||||
func main() {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Ensure that the repositories are organized according to the expected directory structure.
|
||||
migrateRepositories(cwd)
|
||||
|
||||
os.MkdirAll(filepath.Join(cwd, "uploads"), 0755)
|
||||
|
||||
webFS, err := fs.Sub(assets, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Initialize the template function map with helper functions for HTML rendering.
|
||||
funcMap := template.FuncMap{
|
||||
"safe": func(s string) template.HTML { return template.HTML(s) },
|
||||
"iterate": func(count int) []int {
|
||||
var i []int
|
||||
for j := 0; j < count; j++ {
|
||||
i = append(i, j)
|
||||
}
|
||||
return i
|
||||
},
|
||||
"humanize": humanize,
|
||||
"dict": func(values ...interface{}) (map[string]interface{}, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("invalid dict call")
|
||||
}
|
||||
dict := make(map[string]interface{}, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict keys must be strings")
|
||||
}
|
||||
dict[key] = values[i+1]
|
||||
}
|
||||
return dict, nil
|
||||
},
|
||||
"languageColor": func(lang string) string {
|
||||
colors := map[string]string{
|
||||
// Map of programming languages to their respective hex color codes, inspired by GitHub's color palette.
|
||||
"Go": "#00ADD8",
|
||||
"JavaScript": "#F1E05A",
|
||||
"TypeScript": "#3178C6",
|
||||
"Python": "#3572A5",
|
||||
"HTML": "#E34C26",
|
||||
"CSS": "#563D7C",
|
||||
"Java": "#B07219",
|
||||
"C++": "#F34B7D",
|
||||
"C": "#555555",
|
||||
"PHP": "#4F5D95",
|
||||
"Ruby": "#701516",
|
||||
"Rust": "#DEA584",
|
||||
"Shell": "#89E051",
|
||||
"C#": "#178600",
|
||||
"Swift": "#F05138",
|
||||
"Kotlin": "#A97BFF",
|
||||
"Dart": "#00B4AB",
|
||||
"Lua": "#000080",
|
||||
}
|
||||
if c, ok := colors[lang]; ok {
|
||||
return c
|
||||
}
|
||||
return "#8b949e"
|
||||
},
|
||||
}
|
||||
|
||||
tmpl, err := template.New("base").Funcs(funcMap).ParseFS(webFS, "templates/*.html")
|
||||
if err != nil {
|
||||
log.Fatal("Error parsing templates:", err)
|
||||
}
|
||||
|
||||
// Register the handler for the initial setup wizard.
|
||||
http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
tmpl.ExecuteTemplate(w, "setup.html", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
err := r.ParseMultipartForm(10 << 20)
|
||||
if err != nil && err != http.ErrNotMultipart {
|
||||
http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == http.ErrNotMultipart {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Error parsing standard form", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
bio := r.FormValue("bio")
|
||||
githubUser := r.FormValue("github_username")
|
||||
avatarPath := ""
|
||||
|
||||
if githubUser != "" {
|
||||
isStream := r.URL.Query().Get("stream") == "true"
|
||||
var flusher http.Flusher
|
||||
if isStream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
flusher = f
|
||||
}
|
||||
}
|
||||
|
||||
// sendEvent is a helper function to stream Server-Sent Events (SSE) to the client.
|
||||
sendEvent := func(current, total int, msg string) {
|
||||
if isStream && flusher != nil {
|
||||
payload := map[string]interface{}{
|
||||
"current": current,
|
||||
"total": total,
|
||||
"message": msg,
|
||||
}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
fmt.Fprintf(w, "data: %s\n\n", jsonPayload)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent(0, 0, "Fetching Profile...")
|
||||
|
||||
resp, err := http.Get("https://api.github.com/users/" + githubUser)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
var ghUser GithubUser
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ghUser); err == nil {
|
||||
if username == "" {
|
||||
username = ghUser.Name
|
||||
if username == "" {
|
||||
username = ghUser.Login
|
||||
}
|
||||
}
|
||||
if bio == "" {
|
||||
bio = ghUser.Bio
|
||||
}
|
||||
if ghUser.AvatarUrl != "" {
|
||||
sendEvent(0, 0, "Downloading Avatar...")
|
||||
resp, err := http.Get(ghUser.AvatarUrl)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
filename := fmt.Sprintf("gh_avatar_%d.png", time.Now().Unix())
|
||||
destPath := filepath.Join(cwd, "uploads", filename)
|
||||
out, err := os.Create(destPath)
|
||||
if err == nil {
|
||||
defer out.Close()
|
||||
io.Copy(out, resp.Body)
|
||||
avatarPath = "/uploads/" + filename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent(0, 0, "Fetching Repositories...")
|
||||
|
||||
respRepos, err := http.Get("https://api.github.com/users/" + githubUser + "/repos")
|
||||
if err == nil && respRepos.StatusCode == 200 {
|
||||
defer respRepos.Body.Close()
|
||||
var ghRepos []GithubRepo
|
||||
if err := json.NewDecoder(respRepos.Body).Decode(&ghRepos); err == nil {
|
||||
reposDir := filepath.Join(cwd, "repositories")
|
||||
os.MkdirAll(reposDir, 0755)
|
||||
|
||||
totalRepos := len(ghRepos)
|
||||
for i, repo := range ghRepos {
|
||||
sendEvent(i+1, totalRepos, fmt.Sprintf("Cloning %s...", repo.Name))
|
||||
|
||||
target := filepath.Join(reposDir, repo.Name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
exec.Command("git", "clone", repo.CloneUrl, target).Run()
|
||||
}
|
||||
|
||||
if repo.Description != "" {
|
||||
descPath := filepath.Join(target, ".git", "description")
|
||||
os.MkdirAll(filepath.Dir(descPath), 0755)
|
||||
os.WriteFile(descPath, []byte(repo.Description), 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isStream {
|
||||
newConfig := Config{
|
||||
Username: username,
|
||||
GithubUser: githubUser,
|
||||
Bio: bio,
|
||||
Avatar: avatarPath,
|
||||
Socials: Socials{
|
||||
Github: "https://github.com/" + githubUser,
|
||||
},
|
||||
CustomColors: CustomColors{
|
||||
BgColor: "#0d1117",
|
||||
CardBg: "#161b22",
|
||||
BorderColor: "#30363d",
|
||||
AccentColor: "#58a6ff",
|
||||
TextPrimary: "#c9d1d9",
|
||||
TextSecondary: "#8b949e",
|
||||
TextMuted: "#484f58",
|
||||
ButtonText: "#ffffff",
|
||||
},
|
||||
Repositories: []Repository{},
|
||||
}
|
||||
|
||||
files, _ := json.MarshalIndent(newConfig, "", " ")
|
||||
os.WriteFile(filepath.Join(cwd, configFileName), files, 0644)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"redirect": "/",
|
||||
}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
fmt.Fprintf(w, "data: %s\n\n", jsonPayload)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext)
|
||||
destPath := filepath.Join(cwd, "uploads", filename)
|
||||
dst, err := os.Create(destPath)
|
||||
if err == nil {
|
||||
defer dst.Close()
|
||||
io.Copy(dst, file)
|
||||
avatarPath = "/uploads/" + filename
|
||||
}
|
||||
}
|
||||
|
||||
newConfig := Config{
|
||||
Username: username,
|
||||
Bio: bio,
|
||||
Avatar: avatarPath,
|
||||
}
|
||||
saveConfig(filepath.Join(cwd, configFileName), newConfig)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
|
||||
// Register the handler for user settings, including profile and appearance updates.
|
||||
http.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
currentConfig := loadConfig(cwd)
|
||||
currentTab := r.URL.Query().Get("tab")
|
||||
if currentTab == "" {
|
||||
currentTab = "profile"
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
data := SettingsData{
|
||||
Config: currentConfig,
|
||||
CurrentTab: currentTab,
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "settings.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
err := r.ParseMultipartForm(10 << 20)
|
||||
if err != nil && err != http.ErrNotMultipart {
|
||||
http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == http.ErrNotMultipart {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Error parsing standard form", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.FormValue("action") == "update_profile" {
|
||||
currentConfig.Username = r.FormValue("username")
|
||||
currentConfig.Bio = r.FormValue("bio")
|
||||
currentConfig.Email = r.FormValue("email")
|
||||
currentConfig.Socials.Website = r.FormValue("website")
|
||||
currentConfig.Socials.Github = r.FormValue("github")
|
||||
currentConfig.Socials.Linkedin = r.FormValue("linkedin")
|
||||
|
||||
file, handler, err := r.FormFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ext := filepath.Ext(handler.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext)
|
||||
destPath := filepath.Join(cwd, "uploads", filename)
|
||||
dst, err := os.Create(destPath)
|
||||
if err == nil {
|
||||
defer dst.Close()
|
||||
io.Copy(dst, file)
|
||||
currentConfig.Avatar = "/uploads/" + filename
|
||||
}
|
||||
}
|
||||
} else if r.FormValue("action") == "update_appearance" {
|
||||
currentConfig.CustomColors.BgColor = r.FormValue("bg_color")
|
||||
currentConfig.CustomColors.CardBg = r.FormValue("card_bg")
|
||||
currentConfig.CustomColors.BorderColor = r.FormValue("border_color")
|
||||
currentConfig.CustomColors.AccentColor = r.FormValue("accent_color")
|
||||
currentConfig.CustomColors.TextPrimary = r.FormValue("text_primary")
|
||||
currentConfig.CustomColors.TextSecondary = r.FormValue("text_secondary")
|
||||
currentConfig.CustomColors.TextMuted = r.FormValue("text_muted")
|
||||
currentConfig.CustomColors.ButtonText = r.FormValue("button_text")
|
||||
}
|
||||
|
||||
saveConfig(filepath.Join(cwd, configFileName), currentConfig)
|
||||
|
||||
http.Redirect(w, r, "/settings?tab="+currentTab, http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
|
||||
// Register the handler for updating pinned repositories.
|
||||
http.HandleFunc("/api/pins", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Error parsing form", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
currentConfig := loadConfig(cwd)
|
||||
currentConfig.PinnedRepos = r.Form["pinned_repos"] // "pinned_repos" will be a list of repo names
|
||||
saveConfig(filepath.Join(cwd, configFileName), currentConfig)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Register the handler for creating new repositories.
|
||||
http.HandleFunc("/new", func(w http.ResponseWriter, r *http.Request) {
|
||||
currentConfig := loadConfig(cwd)
|
||||
|
||||
if r.Method == "GET" {
|
||||
data := PageData{
|
||||
Config: currentConfig,
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "new.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
repoName := r.FormValue("repo_name")
|
||||
description := r.FormValue("description")
|
||||
initReadme := r.FormValue("init_readme") == "on"
|
||||
|
||||
if repoName == "" {
|
||||
http.Error(w, "Repository name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(cwd, "repositories", repoName)
|
||||
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
http.Error(w, "Failed to create directory: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = targetPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
http.Error(w, "Failed to run git init: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if initReadme {
|
||||
readmePath := filepath.Join(targetPath, "README.md")
|
||||
content := fmt.Sprintf("# %s\n\n%s", repoName, description)
|
||||
if err := os.WriteFile(readmePath, []byte(content), 0644); err == nil {
|
||||
exec.Command("git", "-C", targetPath, "add", "README.md").Run()
|
||||
exec.Command("git", "-C", targetPath, "commit", "-m", "Initial commit").Run()
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/?tab=repositories", http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
|
||||
// Register the handler for browsing repository contents (files, branches, and commits).
|
||||
http.HandleFunc("/repo", func(w http.ResponseWriter, r *http.Request) {
|
||||
currentConfig := loadConfig(cwd)
|
||||
repoName := r.URL.Query().Get("name")
|
||||
subPath := r.URL.Query().Get("path")
|
||||
viewType := r.URL.Query().Get("type")
|
||||
|
||||
if repoName == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
repoRoot := filepath.Join(cwd, "repositories", repoName)
|
||||
|
||||
targetPath := filepath.Join(repoRoot, subPath)
|
||||
if !strings.HasPrefix(targetPath, repoRoot) {
|
||||
http.Error(w, "Invalid path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
action := r.FormValue("action")
|
||||
switch action {
|
||||
case "create_branch":
|
||||
branchName := r.FormValue("branch_name")
|
||||
if branchName != "" {
|
||||
err := createBranch(repoRoot, branchName)
|
||||
if err != nil {
|
||||
log.Println("Error creating branch:", err)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/repo?name="+repoName, http.StatusFound)
|
||||
return
|
||||
case "switch_branch":
|
||||
branch := r.FormValue("branch")
|
||||
if branch != "" {
|
||||
exec.Command("git", "-C", repoRoot, "checkout", branch).Run()
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/repo?name=%s", repoName), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if action == "upload_file" {
|
||||
r.ParseMultipartForm(50 << 20)
|
||||
files := r.MultipartForm.File["file"]
|
||||
|
||||
for _, handler := range files {
|
||||
file, err := handler.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
destPath := filepath.Join(targetPath, handler.Filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err == nil {
|
||||
io.Copy(dst, file)
|
||||
dst.Close()
|
||||
|
||||
if strings.ToLower(filepath.Ext(handler.Filename)) == ".zip" {
|
||||
archive, err := zip.OpenReader(destPath)
|
||||
if err == nil {
|
||||
defer archive.Close()
|
||||
for _, f := range archive.File {
|
||||
filePath := filepath.Join(targetPath, f.Name)
|
||||
if !strings.HasPrefix(filePath, filepath.Clean(targetPath)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(filePath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(filePath), os.ModePerm)
|
||||
dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err == nil {
|
||||
fileInArchive, err := f.Open()
|
||||
if err == nil {
|
||||
io.Copy(dstFile, fileInArchive)
|
||||
fileInArchive.Close()
|
||||
}
|
||||
dstFile.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Remove(destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
||||
exec.Command("git", "-C", repoRoot, "commit", "-m", "Add/Update files via upload").Run()
|
||||
|
||||
backUrl := fmt.Sprintf("/repo?name=%s", repoName)
|
||||
if subPath != "" {
|
||||
backUrl += fmt.Sprintf("&path=%s", subPath)
|
||||
}
|
||||
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if action == "create_path" {
|
||||
name := r.FormValue("name")
|
||||
itemType := r.FormValue("item_type")
|
||||
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
newPath := filepath.Join(targetPath, name)
|
||||
if !strings.HasPrefix(filepath.Clean(newPath), repoRoot) {
|
||||
http.Error(w, "Invalid path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if itemType == "folder" {
|
||||
os.MkdirAll(newPath, 0755)
|
||||
} else {
|
||||
if err := os.WriteFile(newPath, []byte(""), 0644); err != nil {
|
||||
http.Error(w, "Error creating file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
||||
exec.Command("git", "-C", repoRoot, "commit", "-m", "Create "+name).Run()
|
||||
|
||||
relPath, _ := filepath.Rel(repoRoot, newPath)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/repo?name=%s&path=%s&type=edit", repoName, relPath), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
backUrl := fmt.Sprintf("/repo?name=%s", repoName)
|
||||
if subPath != "" {
|
||||
backUrl += fmt.Sprintf("&path=%s", subPath)
|
||||
}
|
||||
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if action == "delete_repo" {
|
||||
if repoRoot != "" && strings.HasPrefix(filepath.Clean(repoRoot), filepath.Join(cwd, "repositories")) {
|
||||
err := os.RemoveAll(repoRoot)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete repo: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if action == "save_file" {
|
||||
content := r.FormValue("content")
|
||||
message := r.FormValue("message")
|
||||
if message == "" {
|
||||
message = "Update " + filepath.Base(subPath)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil {
|
||||
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
||||
exec.Command("git", "-C", repoRoot, "commit", "-m", message).Run()
|
||||
|
||||
backUrl := fmt.Sprintf("/repo?name=%s&path=%s&type=blob", repoName, subPath)
|
||||
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
repoData := Repository{
|
||||
Name: repoName,
|
||||
Path: repoRoot,
|
||||
}
|
||||
|
||||
branches, currentBranch := getBranches(repoRoot)
|
||||
|
||||
var files []FileInfo
|
||||
var commits []Commit
|
||||
var readmeContent string
|
||||
var fileContent string
|
||||
var commitDiffs []FileDiff
|
||||
isHistory := (viewType == "history" || viewType == "commits")
|
||||
isCommit := (viewType == "commit")
|
||||
isEdit := (viewType == "edit")
|
||||
isFile := (viewType == "blob") || isEdit
|
||||
|
||||
if isCommit {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash != "" {
|
||||
commitDiffs = getCommitDiff(repoRoot, hash)
|
||||
}
|
||||
} else if isHistory {
|
||||
commits = getCommits(repoRoot)
|
||||
} else if isFile {
|
||||
content, err := os.ReadFile(targetPath)
|
||||
if err == nil {
|
||||
fileContent = string(content)
|
||||
} else {
|
||||
fileContent = "Error reading file."
|
||||
}
|
||||
} else {
|
||||
if subPath == "" {
|
||||
readmeBytes, err := os.ReadFile(filepath.Join(repoRoot, "README.md"))
|
||||
if err == nil {
|
||||
readmeContent = string(readmeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err == nil && info.IsDir() {
|
||||
entries, _ := os.ReadDir(targetPath)
|
||||
if subPath != "" {
|
||||
parent := filepath.Dir(subPath)
|
||||
if parent == "." {
|
||||
parent = ""
|
||||
}
|
||||
files = append(files, FileInfo{Name: "..", IsDir: true})
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == ".git" {
|
||||
continue
|
||||
}
|
||||
info, _ := e.Info()
|
||||
|
||||
// Retrieve the last modification time from the git log.
|
||||
lastMod := ""
|
||||
relPath := e.Name()
|
||||
if subPath != "" {
|
||||
relPath = filepath.Join(subPath, e.Name())
|
||||
}
|
||||
|
||||
gitLogCmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%ar", "--", relPath)
|
||||
if out, err := gitLogCmd.Output(); err == nil {
|
||||
lastMod = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
files = append(files, FileInfo{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
LastMod: lastMod,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var breadcrumbs []Breadcrumb
|
||||
if subPath != "" {
|
||||
parts := strings.Split(subPath, "/")
|
||||
currentAccumulatedPath := ""
|
||||
for _, part := range parts {
|
||||
if currentAccumulatedPath == "" {
|
||||
currentAccumulatedPath = part
|
||||
} else {
|
||||
currentAccumulatedPath = filepath.Join(currentAccumulatedPath, part)
|
||||
}
|
||||
breadcrumbs = append(breadcrumbs, Breadcrumb{
|
||||
Name: part,
|
||||
Path: currentAccumulatedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data := RepoPageData{
|
||||
Config: currentConfig,
|
||||
Repository: repoData,
|
||||
Files: files,
|
||||
Readme: readmeContent,
|
||||
CurrentPath: subPath,
|
||||
Branches: branches,
|
||||
CurrentBranch: currentBranch,
|
||||
Commits: commits,
|
||||
IsHistory: isHistory,
|
||||
IsCommit: isCommit,
|
||||
IsEdit: isEdit,
|
||||
CommitDiffs: commitDiffs,
|
||||
Breadcrumbs: breadcrumbs,
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "repo.html", struct {
|
||||
RepoPageData
|
||||
FileContent string
|
||||
IsFile bool
|
||||
}{
|
||||
RepoPageData: data,
|
||||
FileContent: fileContent,
|
||||
IsFile: isFile,
|
||||
})
|
||||
})
|
||||
|
||||
// getUserAuthorPatterns compiles a list of author identifiers (username, GitHub handle, email)
|
||||
// associated with the current user. This is used to filter stats in the contribution graph.
|
||||
getUserAuthorPatterns := func(c Config) []string {
|
||||
var patterns []string
|
||||
if c.Username != "" {
|
||||
patterns = append(patterns, c.Username)
|
||||
}
|
||||
if c.GithubUser != "" {
|
||||
patterns = append(patterns, c.GithubUser)
|
||||
}
|
||||
if c.Email != "" {
|
||||
patterns = append(patterns, c.Email)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// Register the handler for repository-specific sub-views (e.g., commit history).
|
||||
http.HandleFunc("/repo/", func(w http.ResponseWriter, r *http.Request) {
|
||||
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/repo/"), "/")
|
||||
if len(pathParts) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
repoName := pathParts[0]
|
||||
action := ""
|
||||
if len(pathParts) > 1 {
|
||||
action = pathParts[1]
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
currentConfig := loadConfig(cwd)
|
||||
repoRoot := filepath.Join(cwd, "repositories", repoName)
|
||||
|
||||
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if action == "commits" {
|
||||
dateStr := r.URL.Query().Get("date")
|
||||
yearStr := r.URL.Query().Get("year")
|
||||
monthStr := r.URL.Query().Get("month")
|
||||
|
||||
var commits []Commit
|
||||
|
||||
if dateStr != "" {
|
||||
commits = getCommitsForDate(repoRoot, dateStr)
|
||||
} else if yearStr != "" && monthStr != "" {
|
||||
year, _ := strconv.Atoi(yearStr)
|
||||
month, _ := strconv.Atoi(monthStr)
|
||||
if year > 0 && month > 0 {
|
||||
commits = getCommitsForMonth(repoRoot, year, month)
|
||||
} else {
|
||||
commits = getCommits(repoRoot)
|
||||
}
|
||||
} else {
|
||||
commits = getCommits(repoRoot)
|
||||
}
|
||||
|
||||
branches, currentBranch := getBranches(repoRoot)
|
||||
|
||||
repoData := Repository{
|
||||
Name: repoName,
|
||||
Path: repoRoot,
|
||||
}
|
||||
|
||||
data := RepoPageData{
|
||||
Config: currentConfig,
|
||||
Repository: repoData,
|
||||
Branches: branches,
|
||||
CurrentBranch: currentBranch,
|
||||
Commits: commits,
|
||||
IsHistory: true,
|
||||
}
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, "repo.html", struct {
|
||||
RepoPageData
|
||||
FileContent string
|
||||
IsFile bool
|
||||
}{
|
||||
RepoPageData: data,
|
||||
IsFile: false,
|
||||
}); err != nil {
|
||||
log.Println("Error executing template:", err)
|
||||
http.Error(w, "Template Error: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if action == "" {
|
||||
http.Redirect(w, r, "/repo?name="+repoName, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// API endpoint to fetch contribution graph data (lazy loaded).
|
||||
http.HandleFunc("/api/contribution-graph", func(w http.ResponseWriter, r *http.Request) {
|
||||
currentConfig := loadConfig(cwd)
|
||||
scanPath := filepath.Join(cwd, "repositories")
|
||||
|
||||
dateStr := r.URL.Query().Get("date")
|
||||
yearStr := r.URL.Query().Get("year")
|
||||
|
||||
authorPatterns := getUserAuthorPatterns(currentConfig)
|
||||
|
||||
var graph []ContributionDay
|
||||
var activity []ActivityMonth
|
||||
var selectedYear int
|
||||
|
||||
if dateStr != "" {
|
||||
activity = scanActivityForDate(scanPath, dateStr, authorPatterns)
|
||||
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
selectedYear = t.Year()
|
||||
} else {
|
||||
selectedYear = time.Now().Year()
|
||||
}
|
||||
} else {
|
||||
selectedYear = time.Now().Year()
|
||||
if yearStr != "" {
|
||||
if y, err := strconv.Atoi(yearStr); err == nil {
|
||||
selectedYear = y
|
||||
}
|
||||
}
|
||||
fmt.Printf("DEBUG Graph Request: Year=%d, Patterns=%v\n", selectedYear, authorPatterns)
|
||||
graph, activity = scanCommitsForYear(scanPath, selectedYear, authorPatterns)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ContributionGraph []ContributionDay
|
||||
ActivityFeed []ActivityMonth
|
||||
SelectedYear int
|
||||
}{
|
||||
ContributionGraph: graph,
|
||||
ActivityFeed: activity,
|
||||
SelectedYear: selectedYear,
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "contribution_graph_inner", data)
|
||||
})
|
||||
|
||||
// API endpoint to initialize the contribution graph on page load.
|
||||
http.HandleFunc("/api/contribution-graph-init", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
currentConfig := loadConfig(cwd)
|
||||
scanPath := filepath.Join(cwd, "repositories")
|
||||
authorPatterns := getUserAuthorPatterns(currentConfig)
|
||||
|
||||
availableYears := getAvailableYears(scanPath, authorPatterns)
|
||||
selectedYear := time.Now().Year()
|
||||
if len(availableYears) > 0 {
|
||||
selectedYear = availableYears[0]
|
||||
}
|
||||
|
||||
graph, activity := scanCommitsForYear(scanPath, selectedYear, authorPatterns)
|
||||
|
||||
data := struct {
|
||||
ContributionGraph []ContributionDay
|
||||
ActivityFeed []ActivityMonth
|
||||
SelectedYear int
|
||||
}{
|
||||
ContributionGraph: graph,
|
||||
ActivityFeed: activity,
|
||||
SelectedYear: selectedYear,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "contribution_graph_inner", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type InitResponse struct {
|
||||
AvailableYears []int `json:"availableYears"`
|
||||
SelectedYear int `json:"selectedYear"`
|
||||
GraphHTML string `json:"graphHTML"`
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(InitResponse{
|
||||
AvailableYears: availableYears,
|
||||
SelectedYear: selectedYear,
|
||||
GraphHTML: buf.String(),
|
||||
})
|
||||
})
|
||||
|
||||
var repoCache []Repository
|
||||
var repoCacheTime time.Time
|
||||
var repoCacheMutex sync.Mutex
|
||||
|
||||
// Main dashboard handler.
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
configPath := filepath.Join(cwd, configFileName)
|
||||
info, err := os.Stat(configPath)
|
||||
if os.IsNotExist(err) || info.Size() == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
currentConfig := loadConfig(cwd)
|
||||
if currentConfig.Username == "" {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
|
||||
scanPath := filepath.Join(cwd, "repositories")
|
||||
os.MkdirAll(scanPath, 0755)
|
||||
|
||||
os.MkdirAll(scanPath, 0755)
|
||||
repoCacheMutex.Lock()
|
||||
if time.Since(repoCacheTime) > 1*time.Minute || len(repoCache) == 0 {
|
||||
currentConfig.Repositories = scanRepositories(scanPath)
|
||||
repoCache = currentConfig.Repositories
|
||||
repoCacheTime = time.Now()
|
||||
} else {
|
||||
currentConfig.Repositories = repoCache
|
||||
}
|
||||
repoCacheMutex.Unlock()
|
||||
|
||||
var availableYears []int
|
||||
var contributionGraph []ContributionDay
|
||||
selectedYear := time.Now().Year()
|
||||
|
||||
var specialReadme string
|
||||
if currentConfig.GithubUser != "" {
|
||||
readmePath := filepath.Join(scanPath, currentConfig.GithubUser, "README.md")
|
||||
if content, err := os.ReadFile(readmePath); err == nil {
|
||||
specialReadme = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
Config: currentConfig,
|
||||
ActiveTab: activeTab,
|
||||
SpecialReadme: specialReadme,
|
||||
ContributionGraph: contributionGraph,
|
||||
AvailableYears: availableYears,
|
||||
SelectedYear: selectedYear,
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "index.html", data)
|
||||
})
|
||||
|
||||
http.Handle("/static/", http.FileServer(http.FS(webFS)))
|
||||
|
||||
http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(filepath.Join(cwd, "uploads")))))
|
||||
|
||||
fmt.Println("🚀 PortaGit est en ligne !")
|
||||
fmt.Println("🌍 Ouvrez votre navigateur sur : http://localhost:8080")
|
||||
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package main
|
||||
|
||||
// Config represents the global configuration for the PortaGit application.
|
||||
// It includes user profile information, theme settings, and cached repository data.
|
||||
type Config struct {
|
||||
Username string `json:"username"`
|
||||
GithubUser string `json:"github_user"`
|
||||
Bio string `json:"bio"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Theme string `json:"theme"`
|
||||
CustomColors CustomColors `json:"custom_colors"`
|
||||
Socials Socials `json:"socials"`
|
||||
PinnedRepos []string `json:"pinned_repos"`
|
||||
Repositories []Repository `json:"repositories"`
|
||||
}
|
||||
|
||||
// Repository holds metadata about a specific git repository.
|
||||
type Repository struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Language string `json:"language"`
|
||||
Path string `json:"path"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CustomColors defines the color palette used in the UI theme.
|
||||
type CustomColors struct {
|
||||
BgColor string `json:"bg_color"`
|
||||
CardBg string `json:"card_bg"`
|
||||
BorderColor string `json:"border_color"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
TextPrimary string `json:"text_primary"`
|
||||
TextSecondary string `json:"text_secondary"`
|
||||
TextMuted string `json:"text_muted"`
|
||||
ButtonText string `json:"button_text"`
|
||||
}
|
||||
|
||||
// Socials contains links to the user's social media profiles.
|
||||
type Socials struct {
|
||||
Linkedin string `json:"linkedin"`
|
||||
Github string `json:"github"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
// PageData is the standard data structure passed to HTML templates.
|
||||
type PageData struct {
|
||||
Config Config
|
||||
ActiveTab string
|
||||
SpecialReadme string
|
||||
ContributionGraph []ContributionDay
|
||||
AvailableYears []int
|
||||
SelectedYear int
|
||||
ActivityFeed []ActivityMonth
|
||||
}
|
||||
|
||||
// ActivityMonth represents aggregated contribution activity for a specific month.
|
||||
type ActivityMonth struct {
|
||||
MonthName string
|
||||
YearInt int
|
||||
MonthInt int
|
||||
Date string
|
||||
TotalCommits int
|
||||
RepoCount int
|
||||
Items []ActivityItem
|
||||
}
|
||||
|
||||
// ActivityItem represents contribution counts for a single repository within a month.
|
||||
type ActivityItem struct {
|
||||
RepoName string
|
||||
Commits int
|
||||
}
|
||||
|
||||
// ContributionDay represents a single day in the contribution graph.
|
||||
type ContributionDay struct {
|
||||
Date string
|
||||
Count int
|
||||
Color string
|
||||
}
|
||||
|
||||
// SettingsData holds data required for rendering the settings page.
|
||||
type SettingsData struct {
|
||||
Config Config
|
||||
CurrentTab string
|
||||
}
|
||||
|
||||
// RepoPageData holds data required for rendering repository-specific views.
|
||||
type RepoPageData struct {
|
||||
Config Config
|
||||
Repository Repository
|
||||
Files []FileInfo
|
||||
Readme string
|
||||
CurrentPath string
|
||||
Branches []string
|
||||
CurrentBranch string
|
||||
Commits []Commit
|
||||
IsHistory bool
|
||||
IsCommit bool
|
||||
IsEdit bool
|
||||
CommitDiffs []FileDiff
|
||||
Breadcrumbs []Breadcrumb
|
||||
}
|
||||
|
||||
// Breadcrumb represents a single navigation item in the repository path.
|
||||
type Breadcrumb struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
// FileInfo represents metadata for a file or directory in the file browser.
|
||||
type FileInfo struct {
|
||||
Name string
|
||||
IsDir bool
|
||||
Size int64
|
||||
LastMod string
|
||||
}
|
||||
|
||||
// Commit represents a single git commit.
|
||||
type Commit struct {
|
||||
Hash string
|
||||
Message string
|
||||
Author string
|
||||
Date string
|
||||
}
|
||||
|
||||
// FileDiff represents the difference content for a specific file in a commit.
|
||||
type FileDiff struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
|
||||
// GithubRepo is used for parsing GitHub API responses for repositories.
|
||||
type GithubRepo struct {
|
||||
Name string `json:"name"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GithubUser is used for parsing GitHub API responses for user profiles.
|
||||
type GithubUser struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Bio string `json:"bio"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
}
|
||||
Loading…
Reference in New Issue