Compare commits

...

41 commits

Author SHA1 Message Date
Stefan Haller
b59ea991ff Enable upload of multiple files on mobile devices 2025-07-21 10:43:26 +02:00
Pierre Dubouilh
d17040df0b fixup symlinks listing appearance
closes #123
2025-02-24 21:23:34 +01:00
Pierre Dubouilh
1cac56abfa hash: nit 2025-02-24 21:23:10 +01:00
Randall Winkhart
ad423948cc Add frontend functionality for checksum feature 2025-02-24 21:23:10 +01:00
Randall Winkhart
da7f84f5c9 Add backend RPC for checksum calculation 2025-02-24 21:23:10 +01:00
Randall Winkhart
23e9e6853f Replace if-else chain in rpc function with switch statement 2025-01-26 16:13:44 +01:00
Randall Winkhart
7644c9f4d0 Update help menu to specify shift as valid modifier for upload keybind 2025-01-26 16:07:44 +01:00
Randall Winkhart
fc4d4dbd09 Work around Firefox's transient activation requirement for opening the file picker (allow using shift as modifier) 2025-01-26 16:07:44 +01:00
Randall Winkhart
8443ab443c Replace deprecated "apple-mobile-web-app-capable" tag 2025-01-24 13:15:27 +01:00
Pierre Dubouilh
31c8ee518a generalise docker images 2024-08-30 18:50:34 +02:00
Pierre Dubouilh
cc703cd70d fixup docker CI 2024-08-30 18:50:34 +02:00
Pierre Dubouilh
c963d1773b fixup case dockerfile
🤷
2024-08-30 14:49:10 +02:00
Pierre Dubouilh
623acc30a6 mpr 2024-08-30 14:44:06 +02:00
Pierre Dubouilh
746d6d55bc bump go to 1.23.0 & fixup build reproducibility 2024-08-30 14:44:06 +02:00
rare-magma
5a1f75265d fix: issues with podman
Signed-off-by: rare-magma <rare-magma@posteo.eu>
2024-08-30 14:20:52 +02:00
rare-magma
d4a60b3ece ci: add user and mount dir under home, delete redundant entrypoint
Signed-off-by: rare-magma <rare-magma@posteo.eu>
2024-08-30 14:20:52 +02:00
rare-magma
c0d7616101 build: harden compose example
Signed-off-by: rare-magma <rare-magma@posteo.eu>
2024-08-30 14:20:52 +02:00
rare-magma
83038f6de2 build: bump alpine version, add healthcheck
Signed-off-by: rare-magma <rare-magma@posteo.eu>
2024-08-30 14:20:52 +02:00
Valentin Heidelberger
a7132076cb caddy config: basicauth -> basic_auth 2024-08-30 12:53:32 +02:00
Pierre Dubouilh
f384c3025b
Update support readme.md 2024-08-04 22:16:22 +02:00
Pierre Dubouilh
5fbc140e53 use init function to inline template 2024-08-04 21:12:56 +02:00
Pierre Dubouilh
1861de0d57 modernize fs calls 2024-08-04 21:12:56 +02:00
Pierre Dubouilh
50c524cc9b fix bug on certain path encoding 2024-08-04 21:12:56 +02:00
Pierre Dubouilh
99a6aec8db add help hint on main page
Co-authored-by: Pietro Bonaldo Gregori <pietro.bogre@gmail.com>
2024-08-04 21:12:56 +02:00
Ivan Kovmir
33c93fc0b4 Fix repository file permissions
Source files should not be executable.
2024-03-27 11:55:39 +01:00
Valentin Heidelberger
ec06354eb0 fix wording 2024-02-09 01:26:34 +01:00
Valentin Heidelberger
b92197cf0f Update Caddyfile config examples to caddy v2 2024-02-09 01:26:34 +01:00
Pierre Dubouilh
b5505d4773 readme + ci 2023-07-08 15:46:47 +02:00
Pierre Dubouilh
58b6840c38 readme 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
17e18cabab log options & stricter checks
closes #93
2023-07-08 15:25:11 +02:00
Pierre Dubouilh
d832a760d2 docker: document mkdir prior to docker call
closes #92
2023-07-08 15:25:11 +02:00
hiranyey
929f30a9bb pdf viewer 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
97c427fb87 lint js 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
7b4987d503 small cleanup gossa.go 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
9de04f2268 fix url with hashes
closes #76
2023-07-08 15:25:11 +02:00
Pierre Dubouilh
a342c79aea let html be displayed naturally
closes #83
2023-07-08 15:25:11 +02:00
Pierre Dubouilh
7b899af6a8 download icon on video page
closes #90
2023-07-08 15:25:11 +02:00
Pierre Dubouilh
c0ff5adaf9 nit ui 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
0a77b08552 bring UI in this repo 2023-07-08 15:25:11 +02:00
Pierre Dubouilh
08ec485e78 enable dependabot 2023-07-08 15:25:11 +02:00
Ning Shang
3ce7703d60 Make make clean output cleaner
Use `rm -f` to suppress errors on removal of nonexistent files.

Also make `clean` a phony target.
2022-10-20 08:51:14 +02:00
21 changed files with 1829 additions and 219 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
ui/** linguist-vendored

View file

@ -6,16 +6,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set up Go - name: Checkout code
uses: actions/setup-go@v1 uses: actions/checkout@v4
with:
go-version: 1.19.1
id: go
- name: Check out code into the Go module directory - name: Set up Go
uses: actions/checkout@v1 uses: actions/setup-go@v5
with: with:
submodules: true go-version: 1.23.0
id: go
- name: Run - name: Run
run: make ci run: make ci

View file

@ -10,33 +10,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v1 uses: actions/checkout@v4
with:
submodules: true
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.19.1
id: go
- name: Set env - name: Set env
run: echo "GIT_TAG=`echo $(git describe --tags --abbrev=0)`" >> $GITHUB_ENV run: echo "GIT_TAG=`echo $(git describe --tags --abbrev=0)`" >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1.10.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and release on dockerhub - name: Build and release on dockerhub
uses: docker/build-push-action@v2.7.0 uses: docker/build-push-action@v6
with: with:
file: support/build.Dockerfile file: support/build.Dockerfile
push: true push: true
@ -47,17 +39,19 @@ jobs:
run: make build-all run: make build-all
- name: "Release gh release versioned" - name: "Release gh release versioned"
uses: ncipollo/release-action@58ae73b360456532aafd58ee170c045abbeaee37 uses: ncipollo/release-action@v1
with: with:
allowUpdates: true allowUpdates: true
artifacts: "builds/*" artifacts: "builds/*"
bodyFile: "builds/buildout"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: "Release gh release latest" - name: "Release gh release latest"
uses: ncipollo/release-action@58ae73b360456532aafd58ee170c045abbeaee37 uses: ncipollo/release-action@v1
with: with:
tag: latest tag: latest
name: Latest name: Latest
allowUpdates: true allowUpdates: true
artifacts: "builds/*" artifacts: "builds/*"
bodyFile: "builds/buildout"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,39 +0,0 @@
name: rc
on:
push:
branches:
- 'rc/**'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.19.1
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
with:
submodules: true
- name: Run
run: make ci
- name: Build all artifacts
run: make build-all
- name: "Release gh release prerelease"
uses: ncipollo/release-action@58ae73b360456532aafd58ee170c045abbeaee37
with:
allowUpdates: true
prerelease: true
tag: "rc"
artifacts: "builds/*"
token: ${{ secrets.GITHUB_TOKEN }}

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "gossa-ui"]
path = gossa-ui
url = https://github.com/pldubouilh/gossa-ui
branch = master

63
Makefile Executable file → Normal file
View file

@ -1,26 +1,26 @@
FLAGS := -ldflags "-s -w" -trimpath FLAGS := -trimpath
NOCGO := CGO_ENABLED=0 NOCGO := CGO_ENABLED=0
build: build::
go vet && go fmt go vet && go fmt
${NOCGO} go build ${FLAGS} -o gossa ${NOCGO} go build ${FLAGS} -o gossa
install: install::
sudo cp gossa /usr/local/bin sudo cp gossa /usr/local/bin
run: run::
./gossa -verb=true test-fixture ./gossa -verb=true test-fixture
run-ro: run-ro::
./gossa -verb=true -ro=true test-fixture ./gossa -verb=true -ro=true test-fixture
run-extra: run-extra::
./gossa -verb=true -prefix="/fancy-path/" -k=false -symlinks=true test-fixture ./gossa -verb=true -prefix="/fancy-path/" -k=false -symlinks=true test-fixture
ci: build-all test ci:: build-all test
echo "done" echo "done"
test: test::
-@cd test-fixture && ln -s ../support .; true -@cd test-fixture && ln -s ../support .; true
go test -cover -c -tags testrunmain go test -cover -c -tags testrunmain
@ -43,32 +43,33 @@ test:
# go tool cover -html all.out # go tool cover -html all.out
# go tool cover -func=all.out | grep main | grep '9.\..\%' # go tool cover -func=all.out | grep main | grep '9.\..\%'
watch: watch::
ls gossa.go gossa_test.go gossa-ui/* | entr -rc make build run ls gossa.go gossa_test.go ui/* | entr -rc make build run
watch-extra: watch-extra::
ls gossa.go gossa_test.go gossa-ui/* | entr -rc make build run-extra ls gossa.go gossa_test.go ui/* | entr -rc make build run-extra
watch-ro: watch-ro::
ls gossa.go gossa_test.go gossa-ui/* | entr -rc make build run-ro ls gossa.go gossa_test.go ui/* | entr -rc make build run-ro
watch-test: watch-test::
ls gossa.go gossa_test.go gossa-ui/* | entr -rc make test ls gossa.go gossa_test.go ui/* | entr -rc make test
build-all: build build-all:: build
${NOCGO} GOOS=linux GOARCH=amd64 go build ${FLAGS} -o builds/gossa-linux-x64 go version
${NOCGO} GOOS=linux GOARCH=arm go build ${FLAGS} -o builds/gossa-linux-arm ${NOCGO} GOOS=linux GOARCH=amd64 go build ${FLAGS} -o builds/gossa-linux-x64
${NOCGO} GOOS=linux GOARCH=arm64 go build ${FLAGS} -o builds/gossa-linux-arm64 ${NOCGO} GOOS=linux GOARCH=arm go build ${FLAGS} -o builds/gossa-linux-arm
${NOCGO} GOOS=darwin GOARCH=amd64 go build ${FLAGS} -o builds/gossa-mac-x64 ${NOCGO} GOOS=linux GOARCH=arm64 go build ${FLAGS} -o builds/gossa-linux-arm64
${NOCGO} GOOS=darwin GOARCH=arm64 go build ${FLAGS} -o builds/gossa-mac-arm64 ${NOCGO} GOOS=darwin GOARCH=amd64 go build ${FLAGS} -o builds/gossa-mac-x64
${NOCGO} GOOS=windows GOARCH=amd64 go build ${FLAGS} -o builds/gossa-windows.exe ${NOCGO} GOOS=darwin GOARCH=arm64 go build ${FLAGS} -o builds/gossa-mac-arm64
sha256sum builds/* ${NOCGO} GOOS=windows GOARCH=amd64 go build ${FLAGS} -o builds/gossa-windows.exe
sha256sum builds/* | tee builds/buildout
clean: clean::
-rm gossa rm -f gossa
-rm gossa-linux64 rm -f gossa-linux64
-rm gossa-linux-arm rm -f gossa-linux-arm
-rm gossa-linux-arm64 rm -f gossa-linux-arm64
-rm gossa-mac rm -f gossa-mac
-rm gossa-windows.exe rm -f gossa-windows.exe

2
go.mod
View file

@ -1,3 +1,3 @@
module github.com/pldubouilh/gossa module github.com/pldubouilh/gossa
go 1.16 go 1.23.0

@ -1 +0,0 @@
Subproject commit 5ec0a804c908654225745ffc8e387859d337c846

119
gossa.go Executable file → Normal file
View file

@ -3,16 +3,21 @@ package main
import ( import (
"archive/zip" "archive/zip"
"compress/gzip" "compress/gzip"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
_ "embed" _ "embed"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"hash"
"html" "html"
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@ -23,6 +28,29 @@ import (
"strings" "strings"
) )
type rowTemplate struct {
Name string
Href template.URL
Size string
Ext string
}
type pageTemplate struct {
Title template.HTML
ExtraPath template.HTML
Ro bool
RowsFiles []rowTemplate
RowsFolders []rowTemplate
}
var host = flag.String("h", "127.0.0.1", "host to listen to")
var port = flag.String("p", "8001", "port to listen to")
var extraPath = flag.String("prefix", "/", "url prefix at which gossa can be reached, e.g. /gossa/ (slashes of importance)")
var symlinks = flag.Bool("symlinks", false, "follow symlinks \033[4mWARNING\033[0m: symlinks will by nature allow to escape the defined path (default: false)")
var verb = flag.Bool("verb", false, "verbosity")
var skipHidden = flag.Bool("k", true, "\nskip hidden files")
var ro = flag.Bool("ro", false, "read only mode (no upload, rename, move, etc...)")
type rpcCall struct { type rpcCall struct {
Call string `json:"call"` Call string `json:"call"`
Args []string `json:"args"` Args []string `json:"args"`
@ -60,9 +88,9 @@ func humanize(bytes int64) string {
} }
func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) { func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) {
_files, err := ioutil.ReadDir(fullPath) files, err := os.ReadDir(fullPath)
check(err) check(err)
sort.Slice(_files, func(i, j int) bool { return strings.ToLower(_files[i].Name()) < strings.ToLower(_files[j].Name()) }) sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name()) })
if !strings.HasSuffix(path, "/") { if !strings.HasSuffix(path, "/") {
path += "/" path += "/"
@ -77,30 +105,36 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
p.Ro = *ro p.Ro = *ro
p.Title = template.HTML(html.EscapeString(title)) p.Title = template.HTML(html.EscapeString(title))
for _, el := range _files { for _, el := range files {
if *skipHidden && strings.HasPrefix(el.Name(), ".") { info, errInfo := el.Info()
continue // dont print hidden files if we're not allowed
}
if !*symlinks && el.Mode()&os.ModeSymlink != 0 {
continue // dont print symlinks if were not allowed
}
el, err := os.Stat(fullPath + "/" + el.Name()) el, err := os.Stat(fullPath + "/" + el.Name())
if err != nil { if err != nil || errInfo != nil {
log.Println("error - cant stat a file", err) log.Println("error - cant stat a file", err)
continue continue
} }
if *skipHidden && strings.HasPrefix(el.Name(), ".") {
continue // dont print hidden files if we're not allowed
}
if !*symlinks && info.Mode()&os.ModeSymlink != 0 {
continue // dont follow symlinks if we're not allowed
}
href := url.PathEscape(el.Name()) href := url.PathEscape(el.Name())
name := el.Name()
if el.IsDir() && strings.HasPrefix(href, "/") { if el.IsDir() && strings.HasPrefix(href, "/") {
href = strings.Replace(href, "/", "", 1) href = strings.Replace(href, "/", "", 1)
} }
if el.IsDir() { if el.IsDir() {
p.RowsFolders = append(p.RowsFolders, rowTemplate{el.Name() + "/", template.HTML(href), "", "folder"}) row := rowTemplate{name + "/", template.URL(href), "", "folder"}
p.RowsFolders = append(p.RowsFolders, row)
} else { } else {
sl := strings.Split(el.Name(), ".") sl := strings.Split(name, ".")
ext := strings.ToLower(sl[len(sl)-1]) ext := strings.ToLower(sl[len(sl)-1])
p.RowsFiles = append(p.RowsFiles, rowTemplate{el.Name(), template.HTML(href), humanize(el.Size()), ext}) row := rowTemplate{name, template.URL(href), humanize(el.Size()), ext}
p.RowsFiles = append(p.RowsFiles, row)
} }
} }
@ -110,9 +144,9 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) // BestSpeed is Much Faster than default - base on a very unscientific local test, and only ~30% larger (compression remains still very effective, ~6x) gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) // BestSpeed is Much Faster than default - base on a very unscientific local test, and only ~30% larger (compression remains still very effective, ~6x)
check(err) check(err)
defer gz.Close() defer gz.Close()
templateParsed.Execute(gz, p) tmpl.Execute(gz, p)
} else { } else {
templateParsed.Execute(w, p) tmpl.Execute(w, p)
} }
} }
@ -199,21 +233,42 @@ func zipRPC(w http.ResponseWriter, r *http.Request) {
func rpc(w http.ResponseWriter, r *http.Request) { func rpc(w http.ResponseWriter, r *http.Request) {
var err error var err error
var rpc rpcCall var rpc rpcCall
defer exitPath(w, "rpc", rpc) defer exitPath(w, "rpc", &rpc)
bodyBytes, err := ioutil.ReadAll(r.Body) bodyBytes, err := io.ReadAll(r.Body)
check(err) check(err)
json.Unmarshal(bodyBytes, &rpc) json.Unmarshal(bodyBytes, &rpc)
ret := []byte("ok")
if rpc.Call == "mkdirp" { switch rpc.Call {
case "mkdirp":
err = os.MkdirAll(enforcePath(rpc.Args[0]), os.ModePerm) err = os.MkdirAll(enforcePath(rpc.Args[0]), os.ModePerm)
} else if rpc.Call == "mv" { case "mv":
err = os.Rename(enforcePath(rpc.Args[0]), enforcePath(rpc.Args[1])) err = os.Rename(enforcePath(rpc.Args[0]), enforcePath(rpc.Args[1]))
} else if rpc.Call == "rm" { case "rm":
err = os.RemoveAll(enforcePath(rpc.Args[0])) err = os.RemoveAll(enforcePath(rpc.Args[0]))
case "sum":
file, err := os.Open(enforcePath(rpc.Args[0]))
check(err)
var hash hash.Hash
switch rpc.Args[1] {
case "md5":
hash = md5.New()
case "sha1":
hash = sha1.New()
case "sha256":
hash = sha256.New()
case "sha512":
hash = sha512.New()
}
_, err = io.Copy(hash, file)
check(err)
checksum := hash.Sum(nil)
ret = make([]byte, hex.EncodedLen(len(checksum)))
hex.Encode(ret, checksum)
} }
check(err) check(err)
w.Write([]byte("ok")) w.Write(ret)
} }
func enforcePath(p string) string { func enforcePath(p string) string {
@ -233,10 +288,10 @@ func enforcePath(p string) string {
} }
func main() { func main() {
if flag.Parse(); len(flag.Args()) > 0 { if flag.Parse(); len(flag.Args()) == 1 {
rootPath = flag.Args()[0] rootPath = flag.Args()[0]
} else { } else {
fmt.Printf("\nusage: ./gossa ~/directory-to-share\n\n") fmt.Printf("\nusage: ./gossa [OPTIONS] ~/directory-to-share\n\n")
flag.PrintDefaults() flag.PrintDefaults()
os.Exit(1) os.Exit(1)
} }
@ -246,16 +301,6 @@ func main() {
check(err) check(err)
server := &http.Server{Addr: *host + ":" + *port, Handler: handler} server := &http.Server{Addr: *host + ":" + *port, Handler: handler}
// clean shutdown - used only for coverage test
// go func() {
// sigchan := make(chan os.Signal, 1)
// signal.Notify(sigchan, os.Interrupt)
// <-sigchan
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// server.Shutdown(ctx)
// }()
if !*ro { if !*ro {
http.HandleFunc(*extraPath+"rpc", rpc) http.HandleFunc(*extraPath+"rpc", rpc)
http.HandleFunc(*extraPath+"post", upload) http.HandleFunc(*extraPath+"post", upload)
@ -264,7 +309,9 @@ func main() {
http.HandleFunc("/", doContent) http.HandleFunc("/", doContent)
handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath))) handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath)))
fmt.Printf("Gossa starting on directory %s\nListening on http://%s:%s%s\n", rootPath, *host, *port, *extraPath) fmt.Printf("Gossa starting on directory %s\n", rootPath)
fmt.Printf("Verbose: %t, Symlinks: %t, Read-Only: %t, Hidden-Files Skipped: %t\n", *verb, *symlinks, *ro, *skipHidden)
fmt.Printf("Listening on http://%s:%s%s\n", *host, *port, *extraPath)
if err = server.ListenAndServe(); err != http.ErrServerClosed { if err = server.ListenAndServe(); err != http.ErrServerClosed {
check(err) check(err)
} }

48
gossa_embed.go Executable file → Normal file
View file

@ -3,48 +3,32 @@ package main
import ( import (
_ "embed" _ "embed"
"encoding/base64" "encoding/base64"
"flag"
"html/template" "html/template"
"strings" "strings"
) )
//go:embed gossa-ui/script.js //go:embed ui/script.js
var scriptJs string var scriptJs string
//go:embed gossa-ui/style.css //go:embed ui/style.css
var styleCss string var styleCss string
//go:embed gossa-ui/favicon.svg //go:embed ui/favicon.svg
var faviconSvg []byte var faviconSvg []byte
//go:embed gossa-ui/ui.tmpl //go:embed ui/ui.tmpl
var templateStr string var uiTmpl string
var tmpl *template.Template
// fill in template // fill in template
var templateCss = strings.Replace(templateStr, "css_will_be_here", styleCss, 1) func init() {
var templateCssJs = strings.Replace(templateCss, "js_will_be_here", scriptJs, 1) var err error
var templateCssJssIcon = strings.Replace(templateCssJs, "favicon_will_be_here", base64.StdEncoding.EncodeToString(faviconSvg), 2) t := strings.Replace(uiTmpl, "css_will_be_here", styleCss, 1)
var templateParsed, _ = template.New("").Parse(templateCssJssIcon) t = strings.Replace(t, "js_will_be_here", scriptJs, 1)
t = strings.Replace(t, "favicon_will_be_here", base64.StdEncoding.EncodeToString(faviconSvg), 2)
type rowTemplate struct { tmpl, err = template.New("").Parse(t)
Name string if err != nil {
Href template.HTML panic(err)
Size string }
Ext string
} }
type pageTemplate struct {
Title template.HTML
ExtraPath template.HTML
Ro bool
RowsFiles []rowTemplate
RowsFolders []rowTemplate
}
var host = flag.String("h", "127.0.0.1", "host to listen to")
var port = flag.String("p", "8001", "port to listen to")
var extraPath = flag.String("prefix", "/", "url prefix at which gossa can be reached, e.g. /gossa/ (slashes of importance)")
var symlinks = flag.Bool("symlinks", false, "follow symlinks \033[4mWARNING\033[0m: symlinks will by nature allow to escape the defined path (default: false)")
var verb = flag.Bool("verb", false, "verbosity")
var skipHidden = flag.Bool("k", true, "\nskip hidden files")
var ro = flag.Bool("ro", false, "read only mode (no upload, rename, move, etc...)")

View file

@ -204,6 +204,17 @@ func doTestRegular(t *testing.T, url string, testExtra bool) {
t.Fatal("post file incorrect path didnt errored") t.Fatal("post file incorrect path didnt errored")
} }
// ~~~~~~~~~~~~~~~~~
fmt.Println("\r\n~~~~~~~~~~ test post file")
path = "2024-01-02-10:36:58.png"
payload = "123123123123123123123123"
body0 = postDummyFile(t, url, path, payload)
body1 = get(t, url+path)
body2 = fetchAndTestDefault(t, url)
if body0 != `ok` || body1 != payload || !strings.Contains(body2, `href="2024-01-02-10:36:58.png"`) {
t.Fatal("post file errored")
}
// ~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~
fmt.Println("\r\n~~~~~~~~~~ test mv rpc") fmt.Println("\r\n~~~~~~~~~~ test mv rpc")
body0 = postJSON(t, url+"rpc", `{"call":"mv","args":["/AAA", "/hols/AAA"]}`) body0 = postJSON(t, url+"rpc", `{"call":"mv","args":["/AAA", "/hols/AAA"]}`)
@ -227,6 +238,8 @@ func doTestRegular(t *testing.T, url string, testExtra bool) {
hasListing := strings.Contains(body0, `readme.md`) hasListing := strings.Contains(body0, `readme.md`)
body1 = get(t, url+"/support/readme.md") body1 = get(t, url+"/support/readme.md")
hasReadme := strings.Contains(body1, `the master branch is automatically built and pushed`) hasReadme := strings.Contains(body1, `the master branch is automatically built and pushed`)
body2 = get(t, url)
hasMainListing := strings.Contains(body2, `href="support">support/</a>`)
if !testExtra && hasReadme { if !testExtra && hasReadme {
t.Fatal("error symlink file reached where illegal") t.Fatal("error symlink file reached where illegal")
@ -238,6 +251,11 @@ func doTestRegular(t *testing.T, url string, testExtra bool) {
} else if testExtra && !hasListing { } else if testExtra && !hasListing {
t.Fatal("error symlink folder unreachable") t.Fatal("error symlink folder unreachable")
} }
if !testExtra && hasMainListing {
t.Fatal("error symlink folder where illegal")
} else if testExtra && !hasMainListing {
t.Fatal("error symlink folder unreachable")
}
if testExtra { if testExtra {
fmt.Println("\r\n~~~~~~~~~~ test symlink mkdir & cleanup") fmt.Println("\r\n~~~~~~~~~~ test symlink mkdir & cleanup")

View file

@ -9,28 +9,32 @@ gossa
a fast and simple webserver for your files, that's dependency-free and with under 250 lines of code, easy to review. a fast and simple webserver for your files, that's dependency-free and with under 250 lines of code, easy to review.
a [simple UI](https://github.com/pldubouilh/gossa-ui) comes as default, featuring : a simple UI comes as default, featuring :
* 🔍 files/directories browser & handler * 🔍 files/directories browser & handler
* 📩 drag-and-drop uploader * 📩 drag-and-drop uploader
* 🥂 fast golang static server
* 💾 90s web UI that prints in milliseconds * 💾 90s web UI that prints in milliseconds
* 📸 video streaming & picture browser * 📸 video streaming, picture browser, pdf viewer
* ✍️ simple note editor * ✍️ simple note editor
* ⌨️ keyboard navigation * ⌨️ keyboard navigation
* 🚀 lightweight and dependency free codebase * 🚀 lightweight and dependency free codebase
* 🔒 >95% test coverage and reproducible builds * 🔒 >95% test coverage and reproducible builds
* 🥂 fast golang static server
* 💑 easy multi account setup, read-only mode * 💑 easy multi account setup, read-only mode
* ✨ PWA enabled * ✨ PWA-able
* 🖥️ multi-platform support
### install / build
[arch linux (AUR)](https://aur.archlinux.org/packages/gossa/) - e.g. `yay -S gossa`
[nix](https://search.nixos.org/packages?channel=unstable&show=gossa&from=0&size=50&sort=relevance&type=packages&query=gossa) - e.g. `nix-shell -p gossa`
[mpr](https://mpr.makedeb.org/packages/gossa)
binaries are available on the [release page](https://github.com/pldubouilh/gossa/releases)
### build
built blobs are available on the [release page](https://github.com/pldubouilh/gossa/releases) - or simply `make build` this repo.
all builds are reproducible, checkout the hashes on the release page. all builds are reproducible, checkout the hashes on the release page.
arch linux users can also install through the [user repos](https://aur.archlinux.org/packages/gossa/) - e.g. `yay -S gossa`
automatic boot-time startup can be handled with a user systemd service - see [support](https://github.com/pldubouilh/gossa/tree/master/support)
### usage ### usage
```sh ```sh
% ./gossa --help % ./gossa --help
@ -46,9 +50,11 @@ release images are pushed to [dockerhub](https://hub.docker.com/r/pldubouilh/gos
```sh ```sh
# pull from dockerhub and run # pull from dockerhub and run
% mkdir ~/LocalDirToShare
% sudo docker run -v ~/LocalDirToShare:/shared -p 8001:8001 pldubouilh/gossa % sudo docker run -v ~/LocalDirToShare:/shared -p 8001:8001 pldubouilh/gossa
``` ```
in a do-one-thing-well mindset, HTTPS and authentication has been left to middlewares and proxies. [sample caddy configs](https://github.com/pldubouilh/gossa/blob/master/support/) are available to quickly setup multi users setups along with https. in a do-one-thing-well mindset, HTTPS and authentication has been left to middlewares and proxies. [sample caddy configs](https://github.com/pldubouilh/gossa/blob/master/support/) are available to quickly setup multi users setups along with https.
automatic boot-time startup can be handled with a user systemd service - see [support](https://github.com/pldubouilh/gossa/tree/master/support)

View file

@ -1,10 +1,16 @@
FROM golang:1.18 as builder FROM docker.io/library/golang:1.23.0-alpine AS builder
RUN apk add --no-cache make
WORKDIR /gossaSrc
COPY . /gossaSrc COPY . /gossaSrc
RUN cd /gossaSrc && make RUN make
FROM alpine:3.15 FROM docker.io/library/alpine:3.20
ENV UID="1000" GID="1000" HOST="0.0.0.0" PORT="8001" PREFIX="/" FOLLOW_SYMLINKS="false" SKIP_HIDDEN_FILES="true" DATADIR="/shared" READONLY="false" VERB="false" ENV UID="1000" GID="1000" HOST="0.0.0.0" PORT="8001" PREFIX="/" FOLLOW_SYMLINKS="false" SKIP_HIDDEN_FILES="true" DATADIR="/shared" READONLY="false" VERB="false"
RUN apk add --no-cache su-exec
COPY ./support/entrypoint.sh /entrypoint.sh
COPY --from=builder /gossaSrc/gossa /gossa COPY --from=builder /gossaSrc/gossa /gossa
ENTRYPOINT "/entrypoint.sh" RUN addgroup -g ${GID} user \
&& adduser -D -u ${UID} -G user user
WORKDIR ${DATADIR}
RUN chown ${UID}:${GID} ${DATADIR}
USER ${UID}:${GID}
ENTRYPOINT /gossa -h ${HOST} -p ${PORT} -k=${SKIP_HIDDEN_FILES} -ro=${READONLY} --symlinks=${FOLLOW_SYMLINKS} --prefix=${PREFIX} --verb=${VERB} ${DATADIR}
HEALTHCHECK --timeout=5s --start-period=5s --retries=3 CMD wget --no-verbose --tries=1 --spider 127.0.0.1:8001 || exit 1

View file

@ -2,9 +2,29 @@ version: '2'
services: services:
gossa-server: gossa-server:
image: pldubouilh/gossa image: docker.io/pldubouilh/gossa:latest
container_name: gossa container_name: gossa
restart: always restart: always
read_only: true
# uncomment to set the user
# user: "1000:1000"
# userns_mode: "keep-id" # uncomment if using rootless podman as well as the x-podman directive at the bottom
# environment:
#- READONLY=true # uncomment to set gossa as read only
# - UID=1000 # this should match the user set above
# - GID=1000 # this should match the user's group
cap_drop:
- ALL
cap_add:
- SETGID
- SETUID
# uncomment to set resource usage limits
# deploy:
# resources:
# limits:
# cpus: "2"
# memory: 250m
# pids: 1024
ports: ports:
- 8001:8001 - 8001:8001
volumes: volumes:
@ -14,3 +34,6 @@ services:
# - "traefik.port=8001" # - "traefik.port=8001"
# - "traefik.backend=gossa" # - "traefik.backend=gossa"
# - "traefik.frontend.rule=Host:${GOSSA}.${DOMAIN}" # - "traefik.frontend.rule=Host:${GOSSA}.${DOMAIN}"
# x-podman: # uncomment if using rootless podman as well as the userns_mode directive at the top
# in_pod: false

View file

@ -1,2 +0,0 @@
#!/bin/sh
exec su-exec ${UID}:${GID} /gossa -h ${HOST} -p ${PORT} -k=${SKIP_HIDDEN_FILES} -ro=${READONLY} --symlinks=${FOLLOW_SYMLINKS} --prefix=${PREFIX} --verb=${VERB} ${DATADIR}

View file

@ -38,36 +38,67 @@ if you prefer building the image yourself :
a docker-compose example image is also provided. running docker compose should be straightforward : `docker-compose up .` have a look in `docker-compose.yml` for further configuration. a docker-compose example image is also provided. running docker compose should be straightforward : `docker-compose up .` have a look in `docker-compose.yml` for further configuration.
## multi-account setup ## multi-account setup with Caddy
authentication / user routing has been left out of the design of gossa, as simple tools are already available for this purpose. [caddy](https://caddyserver.com/v1/) is used here as an example, but other proxy can be used in a similar fashion. authentication / user routing has been left out of the design of gossa, as simple tools are already available for this purpose. [caddy](https://caddyserver.com) is used here as an example, but other proxy can be used in a similar fashion.
### example 1 root, multiple read-only users ### example 1 root, multiple read-only users
this sample caddy config will This sample Caddyfile will
+ enable https on the domain myserver.com + enable https on the domain myserver.com (http will be automatically redirected to https)
+ password protect the access + password protect the access
+ route the root user requests to 1 gossa instance + route the root user requests to 1 gossa instance
+ route user1 and user2 requests to a readonly gossa instance + route user1 and user2 requests to a readonly gossa instance
<details>
<summary>Legacy Caddy v1 Caddyfile</summary>
```sh
myserver.com
# proxy regular and read only instance
proxy / 127.0.0.1:8001
proxy /ro 127.0.0.1:8002 { without /ro }
# reroute non-root user to read-only
# cm9... is the output of `printf "root:password" | base64`
rewrite {
if {>Authorization} not "Basic cm9vdDpwYXNzd29yZA=="
to /ro/{path}
}
# gate access
basicauth / root password
basicauth / ro_user1 passworduser1
basicauth / ro_user2 passworduser2
```
</details>
Caddy v2 Caddyfile
```sh ```sh
myserver.com myserver.com
# proxy regular and read only instance # gate access
proxy / 127.0.0.1:8001 basic_auth {
proxy /ro 127.0.0.1:8002 { without /ro } root $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG # password is "hiccup"
ro_user1 $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG # password is "hiccup"
# reroute non-root user to read-only ro_user2 $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG # password is "hiccup"
# cm9... is the output of `printf "root:password" | base64`
rewrite {
if {>Authorization} not "Basic cm9vdDpwYXNzd29yZA=="
to /ro/{path}
} }
# gate access # named matcher for root user
basicauth / root password @isroot {
basicauth / ro_user1 passworduser1 vars {http.auth.user.id} root
basicauth / ro_user2 passworduser2 }
# proxy regular and read only instance
handle @isroot {
reverse_proxy 127.0.0.1:8001
}
# route non-root user to read only instance
handle {
reverse_proxy 127.0.0.1:8002
}
``` ```
then simply start the 2 gossa instances, and caddy then simply start the 2 gossa instances, and caddy
@ -85,30 +116,59 @@ then simply start the 2 gossa instances, and caddy
### example 2 users on 2 different folders ### example 2 users on 2 different folders
this sample caddy config will This sample Caddyfile will
+ enable https on the domain myserver.com + enable https on the domain myserver.com (http will be automatically redirected to https)
+ password protect the access + password protect the access
+ route user1 to own folder + route user1 to own folder
+ route user2 to own folder + route user2 to own folder
+ share a folder between 2 users with a symlink + share a folder between 2 users with a symlink
<details>
<summary>Legacy Caddy v1 Caddyfile</summary>
```sh
myserver.com
proxy /user1 127.0.0.1:8001 { without /user1 }
proxy /user2 127.0.0.1:8002 { without /user2 }
basicauth / user1 passworduser1
basicauth / user2 passworduser2
rewrite {
if {>Authorization} is "Basic dXNlcjE6cGFzc3dvcmR1c2VyMQ=="
to /user1/{path}
}
rewrite {
if {>Authorization} is "Basic dXNlcjI6cGFzc3dvcmR1c2VyMg=="
to /user2/{path}
}
```
</details>
Caddy v2 Caddyfile
```sh ```sh
myserver.com myserver.com
proxy /user1 127.0.0.1:8001 { without /user1 } basic_auth {
proxy /user2 127.0.0.1:8002 { without /user2 } user1 $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG # password is "hiccup"
user2 $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG # password is "hiccup"
basicauth / user1 passworduser1
basicauth / user2 passworduser2
rewrite {
if {>Authorization} is "Basic dXNlcjE6cGFzc3dvcmR1c2VyMQ=="
to /user1/{path}
} }
rewrite { @user1auth {
if {>Authorization} is "Basic dXNlcjI6cGFzc3dvcmR1c2VyMg==" vars {http.auth.user.id} user1
to /user2/{path} }
handle @user1auth {
reverse_proxy 127.0.0.1:8001
}
@user2auth {
vars {http.auth.user.id} user2
}
handle @user2auth {
reverse_proxy 127.0.0.1:8002
} }
``` ```
@ -124,3 +184,12 @@ start 2 gossa instances, and caddy
% ./gossa -p 8002 -symlinks=true test/user2 & % ./gossa -p 8002 -symlinks=true test/user2 &
% ./caddy % ./caddy
``` ```
## nginx setup
In order to allow for larger uploads, it's recommended to increase the maximum body size on your nginx config :
```
# increase maximum request size
client_max_body_size 100M;
```

1
ui/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

4
ui/readme.md vendored Normal file
View file

@ -0,0 +1,4 @@
gossa-ui
========
a ~~ugly~~ simple, dependency-free, plain js/css/html front-end for [gossa](https://github.com/pldubouilh/gossa)

925
ui/script.js vendored Executable file
View file

@ -0,0 +1,925 @@
/* eslint-env browser */
/* eslint-disable no-multi-str */
function cancelDefault (e) {
e.preventDefault()
e.stopPropagation()
}
const warningMsg = () => 'Leaving will interrupt transfer?\n'
const rmMsg = () => !confirm('Remove file?\n')
const ensureMove = () => !confirm('move items?')
const isRo = () => window.ro
// DOM elements
const upBarName = document.getElementById('upBarName')
const upBarPc = document.getElementById('upBarPc')
const upGrid = document.getElementById('drop-grid')
const pics = document.getElementById('pics')
const picsHolder = document.getElementById('picsHolder')
const video = document.getElementById('video')
const videoHolder = document.getElementById('videoHolder')
const manualUpload = document.getElementById('clickupload')
const pdf = document.getElementById('pdf')
const help = document.getElementById('help')
const okBadge = document.getElementById('ok')
const sadBadge = document.getElementById('sad')
const pageTitle = document.head.querySelector('title')
const pageH1 = document.body.querySelector('h1')
const editor = document.getElementById('text-editor')
const crossIcon = document.getElementById('quitAll')
const toast = document.getElementById('toast')
const table = document.getElementById('linkTable')
const helpMsg = document.getElementById('help_message')
const transparentPixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
// helpers
let allA
let imgsIndex
let allImgs
const decode = a => decodeURIComponent(a).replace(location.origin, '')
const getArrowSelected = () => document.querySelector('.arrow-selected')
const getASelected = () => !getArrowSelected() ? false : getArrowSelected().parentElement.parentElement.querySelectorAll('a')[0]
const prependPath = a => a.startsWith('/') ? a : decodeURI(location.pathname) + a
const prevent = e => e.preventDefault()
const flicker = w => w.classList.remove('runFade') || void w.offsetWidth || w.classList.add('runFade') // eslint-disable-line
const encodeURIHash = e => encodeURI(e).replaceAll('#', '%23')
// Manual upload
manualUpload.addEventListener('change', () => Array.from(manualUpload.files).forEach(f => isDupe(f.name) || postFile(f, '/' + f.name)), false)
// Soft nav
async function browseTo (href, flickerDone, skipHistory) {
try {
const r = await fetch(href, { credentials: 'include' })
const t = await r.text()
const parsed = new DOMParser().parseFromString(t, 'text/html')
table.innerHTML = parsed.getElementById('linkTable').innerHTML
const title = parsed.head.querySelector('title').innerText
// check if is current path - if so skip following
if (pageTitle.innerText !== title) {
if (!skipHistory) {
const escaped = encodeURIHash(window.extraPath + title)
history.pushState({}, '', escaped)
}
pageTitle.innerText = title
pageH1.innerText = '.' + title
setTitle()
}
init()
if (flickerDone) flicker(okBadge)
} catch (error) {
flicker(sadBadge)
}
}
window.onClickLink = e => {
const a = e ? e.target : getASelected()
if (e) {
setCursorTo(e.target.innerText)
}
// always force download if ctrl pressed (also covers zipping folders)
if (e && e.ctrlKey) {
dl(a)
return false
}
// follow dirs
if (isFolder(a)) {
browseTo(a.href)
return false
// enable notepad if relevant
} else if (!window.ro && isTextFile(a.innerText) && !isEditorMode()) {
padOn(a)
return false
// toggle picture carousel
} else if (isPic(a.href) && !isPicMode()) {
picsOn(a.href)
return false
// toggle videos mode
} else if (isVideo(a.href) && !isVideoMode()) {
videoOn(a.href)
return false
// let html be displayed naturally
} else if (a.innerText.endsWith('.html')) {
return true
} else if (isPdf(a.href)) {
openPDF(a.href)
return false
}
// else just force download
dl(a)
return false
}
let softStatePushed
function pushSoftState (d) {
if (softStatePushed) { return }
softStatePushed = true
history.pushState({}, '', encodeURIHash(d))
}
const refresh = () => browseTo(location.href, true)
const softPrev = () => history.replaceState({}, '', location.href.split('/').slice(0, -1).join('/') + '/')
const isAtExtraPath = url => location.origin + window.extraPath + '/../' === url
const prevPage = (url, skipHistory) => window.quitAll() || isAtExtraPath(url) || browseTo(url, false, skipHistory)
window.onpopstate = () => prevPage(location.href, true)
// RPC
function upload (id, what, path, cbDone, cbErr, cbUpdate) {
const xhr = new XMLHttpRequest()
xhr.open('POST', location.origin + window.extraPath + '/post')
xhr.setRequestHeader('gossa-path', path)
xhr.upload.addEventListener('load', cbDone)
xhr.upload.addEventListener('progress', cbUpdate)
xhr.upload.addEventListener('error', cbErr)
xhr.upload.id = id
xhr.send(what)
}
function rpc (call, args, cb) {
console.log('RPC', call, args)
const xhr = new XMLHttpRequest()
xhr.open('POST', location.origin + window.extraPath + '/rpc')
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8')
xhr.send(JSON.stringify({ call, args }))
xhr.onload = cb
xhr.onerror = () => flicker(sadBadge)
}
const mkdirCall = (path, cb) => rpc('mkdirp', [prependPath(path)], cb)
const rmCall = (path1, cb) => rpc('rm', [prependPath(path1)], cb)
const mvCall = (path1, path2, cb) => rpc('mv', [path1, path2], cb)
const sumCall = (path, type, cb) => rpc('sum', [prependPath(path), type], cb)
// File upload
let totalDone = 0
let totalUploads = 0
let totalUploadsSize = 0
let totalUploadedSize = []
const dupe = test => allA.find(a => a.innerHTML.replace('/', '') === test)
const isDupe = t => dupe(t) ? alert(t + ' already already exists') || true : false
function shouldRefresh () {
totalDone += 1
if (totalUploads === totalDone) {
window.onbeforeunload = null
console.log('done uploading ' + totalDone + ' files')
totalDone = 0
totalUploads = 0
totalUploadsSize = 0
totalUploadedSize = []
upBarPc.style.display = upBarName.style.display = 'none'
table.classList.remove('uploading-table')
setTimeout(refresh, 200)
}
}
function updatePercent (ev) {
totalUploadedSize[ev.target.id] = ev.loaded
const ttlDone = totalUploadedSize.reduce((s, x) => s + x)
const pc = Math.floor(100 * ttlDone / totalUploadsSize) + '%'
upBarPc.innerText = pc
upBarPc.style.width = pc
}
function postFile (file, path) {
if (window.ro) return
path = decodeURI(location.pathname).slice(0, -1) + path
window.onbeforeunload = warningMsg
table.classList.add('uploading-table')
upBarPc.style.display = upBarName.style.display = 'block'
totalUploads += 1
totalUploadsSize += file.size
upBarName.innerText = totalUploads > 1 ? totalUploads + ' files' : file.name
const formData = new FormData()
formData.append(file.name, file)
upload(totalUploads, formData, encodeURIComponent(path), shouldRefresh, null, updatePercent)
}
const parseDomFolder = f => f.createReader().readEntries(e => e.forEach(i => parseDomItem(i)))
function parseDomItem (domFile, shoudCheckDupes) {
if (shoudCheckDupes && isDupe(domFile.name)) {
return
}
if (domFile.isFile) {
domFile.file(f => postFile(f, domFile.fullPath))
} else {
// remove absolute path
const f = domFile.fullPath.startsWith('/') ? domFile.fullPath.slice(1) : domFile.fullPath
mkdirCall(f, () => parseDomFolder(domFile))
}
}
function pushEntry (entry) {
if (!entry.webkitGetAsEntry && !entry.getAsEntry) {
return alert('Unsupported browser ! Please update to chrome/firefox.')
} else {
entry = entry.webkitGetAsEntry() || entry.getAsEntry()
}
parseDomItem(entry, true)
}
window.titleClick = function (e) {
const p = Array.from(document.querySelector('h1').childNodes).map(k => k.innerText)
const i = p.findIndex(s => s === e.target.innerText)
const dst = p.slice(0, i + 1).join('').slice(1)
const target = location.origin + window.extraPath + encodeURIHash(dst)
browseTo(target, false)
}
// Move files and folders
const isFolder = e => e && e.href && e.innerText.endsWith('/')
const setBackgroundLinks = t => { t.classList.add('highlight') }
const getLink = () => document.querySelector('.highlight') || {}
const resetBackgroundLinks = () => { try { getLink().classList.remove('highlight') } catch(e) { /* */ } } // eslint-disable-line
// Not the nicest - sometimes, upon hover, firefox reports nodeName === '#text', and chrome reports nodeName === 'A'...
const getClosestRow = t => t.nodeName === '#text' ? t.parentElement.parentElement : t.nodeName === 'A' ? t.parentElement : t
let draggingSrc
upGrid.ondragend = upGrid.ondragexit = upGrid.ondragleave = e => {
cancelDefault(e)
upGrid.style.display = 'none'
}
// Handle hover
document.ondragenter = e => {
if (isEditorMode() || isPicMode() || window.ro) { return }
cancelDefault(e)
resetBackgroundLinks()
// Display upload grid when uploading new elements
if (!draggingSrc) {
upGrid.style.display = 'flex'
e.dataTransfer.dropEffect = 'copy'
// Or highlight entry if drag and drop
} else if (draggingSrc) {
const t = getClosestRow(e.target)
isFolder(t.firstChild) && setBackgroundLinks(t)
}
}
document.ondragstart = e => { draggingSrc = e.target.innerHTML }
document.ondragend = e => resetBackgroundLinks()
document.ondragover = e => {
cancelDefault(e)
return false
}
// Handle drop
document.ondrop = e => {
if (window.ro) return
cancelDefault(e)
upGrid.style.display = 'none'
const t = getLink().firstChild
// move to a folder
if (draggingSrc && t) {
const dest = t.innerHTML + draggingSrc
ensureMove() || mvCall(prependPath(draggingSrc), prependPath(dest), refresh)
// ... or upload
} else if (e.dataTransfer.items.length) {
Array.from(e.dataTransfer.items).forEach(pushEntry)
}
resetBackgroundLinks()
draggingSrc = null
return false
}
// Notepad
const isEditorMode = () => editor.style.display === 'block'
const textTypes = ['.txt', '.rtf', '.md', '.markdown', '.log', '.yaml', '.yml']
const isTextFile = src => src && textTypes.find(type => src.toLocaleLowerCase().includes(type))
let fileEdited
function saveText (quitting) {
const formData = new FormData()
formData.append(fileEdited, editor.value)
const path = encodeURIComponent(decodeURI(location.pathname) + fileEdited)
upload(0, formData, path, () => {
toast.style.display = 'none'
if (!quitting) return
clearInterval(window.padTimer)
window.onbeforeunload = null
resetView()
softPrev()
refresh()
}, () => {
toast.style.display = 'block'
if (!quitting) return
alert('cant save!\r\nleave window open to resume saving\r\nwhen connection back up')
})
}
function padOff () {
if (!isEditorMode()) { return }
saveText(true)
return true
}
async function padOn (a) {
if (a) {
try {
fileEdited = a.innerHTML
const f = await fetch(a.href, {
credentials: 'include',
headers: new Headers({ pragma: 'no-cache', 'cache-control': 'no-cache' })
})
editor.value = await f.text()
} catch (error) {
return alert('cant read file')
}
} else {
fileEdited = prompt('new filename', '')
if (!fileEdited) { return }
fileEdited = isTextFile(fileEdited) ? fileEdited : fileEdited + '.txt'
if (isDupe(fileEdited)) { return }
editor.value = ''
}
console.log('editing file', fileEdited)
setCursorTo(fileEdited)
editor.style.display = crossIcon.style.display = 'block'
table.style.display = 'none'
editor.focus()
window.onbeforeunload = warningMsg
window.padTimer = setInterval(saveText, 5000)
pushSoftState('?editor=' + fileEdited)
}
window.displayPad = padOn
// quit pictures or editor
function resetView () {
softStatePushed = false
table.style.display = 'table'
picsHolder.src = transparentPixel
videoHolder.src = ''
pdf.innerHTML = ''
editor.style.display = pics.style.display = video.style.display = pdf.style.display = crossIcon.style.display = 'none'
scrollToArrow()
}
window.quitAll = () => helpOff() || sumsOff() || picsOff() || videosOff() || padOff() || pdfOff()
// Mkdir icon
window.mkdirBtn = function () {
const folder = prompt('new folder name', '')
if (folder && !isDupe(folder)) {
mkdirCall(folder, refresh)
}
}
// Icon click handler
const getBtnA = e => e.target.closest('tr').querySelector('a')
window.rm = e => {
if (window.ro) return true
clearTimeout(window.clickToken)
const target = e.key ? getASelected() : getBtnA(e)
if (target.innerText === '../') return
if (rmMsg()) return
moveArrow()
rmCall(decode(target.href), refresh)
}
window.rename = (e, commit) => {
if (window.ro) return true
clearTimeout(window.clickToken)
if (!commit) {
window.clickToken = setTimeout(window.rename, 300, e, true)
return
}
const target = e.key ? getASelected() : getBtnA(e)
if (target.innerText === '../') return
const chg = prompt('rename to', target.innerText)
if (chg && !isDupe(chg)) {
mvCall(prependPath(target.innerText), prependPath(chg), refresh)
}
}
function aboveBelowRightin (el) {
const itemPos = el.getBoundingClientRect()
return itemPos.top < 0 ? -1 : itemPos.bottom > window.innerHeight ? 1 : 0
}
function scrollToArrow () {
const el = getASelected()
while (1) {
const pos = aboveBelowRightin(el)
if (pos === -1) {
scrollBy(0, -300)
} else if (pos === 1) {
scrollBy(0, 300)
} else {
break
}
}
}
function clearArrowSelected () {
const arr = getArrowSelected()
if (!arr) { return }
arr.classList.remove('arrow-selected')
}
window.setCursorTo = setCursorTo
function setCursorTo (where) {
if (!where) return false
clearArrowSelected()
let a = allA.find(el => el.innerText === where || el.innerText === where + '/')
if (!a) {
if (allA[0].innerText === '../') {
a = allA[1] || allA[0]
} else {
a = allA[0]
}
}
const icon = a.parentElement.parentElement.querySelectorAll('.arrow-icon')[0]
icon.classList.add('arrow-selected')
scrollToArrow()
storeArrow(where)
return true
}
function moveArrow (down) {
const all = Array.from(document.querySelectorAll('.arrow-icon'))
let i = all.findIndex(el => el.classList.contains('arrow-selected'))
clearArrowSelected()
if (down) {
i = all[i + 1] ? i + 1 : 0
} else {
i = all[i - 1] ? i - 1 : all.length - 1
}
all[i].classList.add('arrow-selected')
storeArrow(getASelected().innerText)
scrollToArrow()
}
const storeArrow = src => localStorage.setItem('last-selected' + window.extraPath + location.pathname, src)
const isTop = () => window.scrollY === 0
const isBottom = () => (window.innerHeight + window.scrollY) >= document.body.offsetHeight
const hasScroll = () => table.clientHeight > window.innerHeight
function movePage (up) {
const current = getASelected().href
if (!hasScroll()) return
if (!up) {
const i = allA.findIndex(e => aboveBelowRightin(e) === 1)
if (isTop() && current !== allA[i - 1].href) {
return setCursorTo(allA[i - 1].innerText)
} else if (isBottom() && current !== allA[allA.length - 1].href) {
return setCursorTo(allA[allA.length - 1].innerText)
}
if (!allA[i - 1]) return
setCursorTo(allA[i - 1].innerText)
scrollBy(0, window.innerHeight - 100)
} else {
const i = allA.findIndex(e => aboveBelowRightin(e) === 0)
if (isTop() && current !== allA[0].href) {
return setCursorTo(allA[0].innerText)
} else if (isBottom() && current !== allA[i].href) {
return setCursorTo(allA[i].innerText)
}
scrollBy(0, -(window.innerHeight - 100))
setCursorTo(allA[i].innerText)
}
}
// Pictures carousel
const picTypes = ['.jpg', '.jpeg', '.png', '.gif']
const isPic = src => src && picTypes.find(type => src.toLocaleLowerCase().includes(type))
const isPicMode = () => pics.style.display === 'flex'
window.picsNav = () => picsNav(true)
function setImage () {
const src = allImgs[imgsIndex]
picsHolder.src = src
const name = src.split('/').pop()
setCursorTo(decodeURI(name))
history.replaceState({}, '', encodeURIHash(name))
}
function picsOn (href) {
imgsIndex = allImgs.findIndex(el => el.includes(href))
setImage()
table.style.display = 'none'
crossIcon.style.display = 'block'
pics.style.display = 'flex'
const name = href.split('/').pop()
pushSoftState(name)
return true
}
function picsOff () {
if (!isPicMode()) { return }
resetView()
softPrev()
return true
}
function picsNav (down) {
if (!isPicMode()) { return false }
if (down) {
imgsIndex = allImgs[imgsIndex + 1] ? imgsIndex + 1 : 0
} else {
imgsIndex = allImgs[imgsIndex - 1] ? imgsIndex - 1 : allImgs.length - 1
}
setImage()
return true
}
let picsTouchStart = 0
picsHolder.addEventListener('touchstart', e => {
picsTouchStart = e.changedTouches[0].screenX
}, false)
picsHolder.addEventListener('touchend', e => {
if (e.changedTouches[0].screenX < picsTouchStart) {
picsNav(true)
} else if (e.changedTouches[0].screenX > picsTouchStart) {
picsNav(false)
}
}, false)
// Video player
const videosTypes = ['.mp4', '.webm', '.ogv', '.ogg', '.mp3', '.flac', '.wav']
const isVideo = src => src && videosTypes.find(type => src.toLocaleLowerCase().includes(type))
const isVideoMode = () => video.style.display === 'flex'
const videoFs = () => video.requestFullscreen()
const videoFf = future => { videoHolder.currentTime += future ? 10 : -10 }
const videoSound = up => { videoHolder.volume += up ? 0.1 : -0.1 }
videoHolder.oncanplay = () => videoHolder.play()
async function videoOn (src) {
const name = src.split('/').pop()
table.style.display = 'none'
crossIcon.style.display = 'block'
video.style.display = 'flex'
videoHolder.pause()
const time = localStorage.getItem('video-time' + src)
videoHolder.currentTime = parseInt(time) || 0
videoHolder.src = src
pushSoftState(decodeURI(name))
return true
}
function videosOff () {
if (!isVideoMode()) { return }
localStorage.setItem('video-time' + videoHolder.src, videoHolder.currentTime)
resetView()
softPrev()
return true
}
window.videodl = function () {
dl(getASelected())
}
// PDF Viewer
const pdfTypes = ['.pdf']
const isPdf = src => src && pdfTypes.find(type => src.toLocaleLowerCase().includes(type))
const isPdfMode = () => pdf.style.display === 'flex'
function openPDF (src) {
const name = src.split('/').pop()
table.style.display = 'none'
crossIcon.style.display = 'block'
pdf.style.display = 'flex'
const pdfEmbed = document.createElement('embed')
pdfEmbed.setAttribute('src', src + '#toolbar=0')
pdfEmbed.setAttribute('type', 'application/pdf')
pdf.appendChild(pdfEmbed)
pushSoftState(decodeURI(name))
return false
}
function pdfOff () {
if (!isPdfMode()) { return }
resetView()
softPrev()
return true
}
// help
const isHelpMode = () => help.style.display === 'block'
const helpToggle = () => isHelpMode() ? helpOff() : helpOn()
function helpOn () {
help.style.display = 'block'
table.style.display = 'none'
}
window.helpOff = helpOff
function helpOff () {
if (!isHelpMode()) return
help.style.display = 'none'
table.style.display = 'table'
return true
}
// checksums
function getSum (type) {
upBarPc.style.display = 'block'
upBarPc.innerText = 'computing checksum...'
upBarPc.style.width = '100%'
sumsOff()
sumCall(getASelected().innerText, type, loaded => {
navigator.clipboard.writeText(loaded.target.responseText)
upBarPc.style.display = 'none'
flicker(okBadge)
})
}
const isSumsMode = () => sums.style.display === 'block'
const sumsToggle = () => isSumsMode() ? sumsOff() : sumsOn()
function sumsOn () {
if (isFolder(getASelected())) {
alert('cannot checksum a directory')
return
}
sums.style.display = 'block'
table.style.display = 'none'
}
window.sumsOff = sumsOff
function sumsOff () {
if (!isSumsMode()) return
sums.style.display = 'none'
table.style.display = 'table'
return true
}
// Paste handler
const cuts = []
function onPaste () {
if (!cuts.length) { return refresh() }
const a = getASelected()
const root = cuts.pop()
const filename = root.split('/').pop()
const pwd = decodeURIComponent(location.pathname)
const dest = isFolder(a) ? pwd + a.innerHTML : pwd
mvCall(root, dest + filename, onPaste)
}
function onCut () {
const a = getASelected()
a.classList.add('linkSelected')
cuts.push(prependPath(decode(a.href)))
}
function dl (a) {
const orig = a.onclick
a.onclick = ''
// download as zip if folder
if (isFolder(a)) {
const loc = a.href
a.href = window.extraPath + '/zip?zipPath=' + encodeURIComponent(prependPath(a.innerText)) + '&zipName=' + encodeURIComponent(a.innerText.slice(0, -1))
a.click()
a.href = loc
} else {
a.download = a.innerText
a.click()
a.download = ''
}
a.onclick = orig
}
// Kb handler
let typedPath = ''
let typedToken = null
function cpPath () {
const t = document.createElement('textarea')
t.value = getASelected().href
document.body.appendChild(t)
t.select()
document.execCommand('copy')
document.body.removeChild(t)
}
document.body.addEventListener('keydown', e => {
if (e.code === 'Escape') {
return resetBackgroundLinks() || window.quitAll()
}
if (isHelpMode()) { return prevent(e) || window.quitAll() }
if (isEditorMode()) { return }
if (isPicMode()) {
switch (e.code) {
case 'ArrowLeft':
case 'ArrowUp':
return prevent(e) || picsNav(false)
case 'Enter':
case 'Tab':
case 'ArrowRight':
case 'ArrowDown':
return prevent(e) || picsNav(true)
}
return
}
if (isVideoMode()) {
switch (e.code) {
case 'ArrowDown':
case 'ArrowUp':
return prevent(e) || videoSound(e.code === 'ArrowUp')
case 'ArrowLeft':
case 'ArrowRight':
return prevent(e) || videoFf(e.code === 'ArrowRight')
case 'KeyF':
return prevent(e) || videoFs()
}
return
}
// Modifier keys
if (!e.shiftKey) {
if (e.ctrlKey || e.metaKey) {
switch (e.code) {
case 'KeyC':
return prevent(e) || isRo() || cpPath()
case 'KeyH':
return prevent(e) || isRo() || helpToggle()
case 'KeyZ':
return prevent(e) || isRo() || sumsToggle()
case 'KeyX':
return prevent(e) || isRo() || onCut()
case 'KeyR':
return prevent(e) || refresh()
case 'KeyV':
return prevent(e) || isRo() || ensureMove() || onPaste()
case 'Backspace':
return prevent(e) || isRo() || window.rm(e)
case 'KeyE':
return prevent(e) || isRo() || window.rename(e)
case 'KeyM':
return prevent(e) || isRo() || window.mkdirBtn()
case 'KeyU':
return prevent(e) || isRo() || manualUpload.click()
case 'Enter':
case 'ArrowRight':
return prevent(e) || dl(getASelected())
}
} else if (isSumsMode()) {
switch (e.code) {
case 'Digit1':
return prevent(e) || isRo() || getSum('sha1')
case 'Digit2':
return prevent(e) || isRo() || getSum('sha256')
case 'Digit3':
return prevent(e) || isRo() || getSum('sha512')
case 'Digit5':
return prevent(e) || isRo() || getSum('md5')
}
}
} else {
// Workaround Firefox requirement for transient activation
// https://developer.mozilla.org/en-US/docs/Web/Security/User_activation
// Firefox requires user interaction (that is not reserved by the user agent)
// before a file picker can be displayed. This means that ctrl/meta are not
// usable as modifiers until the user clicks the page or presses another
// non-modifier key. To work around this, the shift key can be used, instead.
if (e.code == 'KeyU') {
return prevent(e) || isRo() || manualUpload.click()
}
}
switch (e.code) {
case 'Tab':
case 'ArrowDown':
return prevent(e) || moveArrow(true)
case 'ArrowUp':
return prevent(e) || moveArrow(false)
case 'Enter':
case 'ArrowRight':
return prevent(e) || window.onClickLink()
case 'ArrowLeft':
return prevent(e) || prevPage(location.href + '../')
case 'PageDown':
case 'PageUp':
return prevent(e) || movePage(e.key === 'PageUp')
case 'Space':
return prevent(e) || movePage(e.shiftKey)
}
// text search
if (e.code.includes('Key') && !e.ctrlKey && !e.metaKey) {
typedPath += e.code.replace('Key', '').toLocaleLowerCase()
clearTimeout(typedToken)
typedToken = setTimeout(() => { typedPath = '' }, 1000)
const a = allA.find(el => el.innerText.toLocaleLowerCase().startsWith(typedPath)) || allA.find(el => el.innerText.toLocaleLowerCase().includes(typedPath))
if (!a) { return }
setCursorTo(a.innerText)
}
}, false)
function setTitle () {
pageH1.innerHTML = '<span>' + pageH1.innerText.split('/').join('/</span><span>') + '</span>'
}
function init () {
allA = Array.from(document.querySelectorAll('a.list-links'))
allImgs = allA.map(el => el.href).filter(isPic)
imgsIndex = softStatePushed = 0
const successRestore = setCursorTo(localStorage.getItem('last-selected' + window.extraPath + location.pathname))
if (!successRestore) {
const entries = table.querySelectorAll('.arrow-icon')
entries.length === 1 ? entries[0].classList.add('arrow-selected') : entries[1].classList.add('arrow-selected')
}
setTitle()
scrollToArrow()
console.log('browsed to ' + location.href)
if (cuts.length) {
const match = allA.filter(a => cuts.find(c => c === decode(a.href)))
match.forEach(m => m.classList.add('linkSelected'))
}
// restore editor if was queried
if (location.search.includes('?editor=')) {
const cleanURL = location.href.replace('?editor=', '')
const matchingA = allA.find(a => a.href === cleanURL)
padOn(matchingA)
}
// check if we're at root path
if (location.pathname === window.extraPath + '/') {
helpMsg.style.display = 'block'
} else {
helpMsg.style.display = 'none'
}
}
init()

481
ui/style.css vendored Normal file

File diff suppressed because one or more lines are too long

98
ui/ui.tmpl vendored Normal file
View file

@ -0,0 +1,98 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="theme-color" content="rgb(45,52,54)">
<meta name="msapplication-navbutton-color" content="rgb(45,52,54)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width">
<link rel="manifest" href='data:application/manifest+json,{"name":"{{.Title}}","short_name":"{{.Title}}","description":" ","icons":[{"src":"data:image/svg+xml;base64,favicon_will_be_here","sizes":"150x150","type":"image/svg+xml"}],"background":"rgb(45,52,54)","theme_color":"rgb(45,52,54)","display":"standalone"}' />
<title>{{.Title}}</title>
<link href="data:image/svg+xml;base64,favicon_will_be_here" rel="icon" type="image/svg+xml" />
<style type="text/css">css_will_be_here</style>
<script>
window.ro = {{.Ro}}
window.extraPath = {{.ExtraPath}}.slice(0, -1)
window.onload = function () { js_will_be_here }
</script>
</head>
<body>
<div onclick="window.helpOff()" style="display: none;" id="help"><table id="helpTable"><tbody>
<tr><td>Arrows/Enter</td><td>browse files/folders and pictures</td></tr>
<tr><td>Ctrl/Meta + Enter</td><td>download selected item as archive</td></tr>
<tr><td>Ctrl/Meta + C</td><td>copy URL to clipboard</td></tr>
<tr><td>Ctrl/Meta + E</td><td>rename item</td></tr>
<tr><td>Ctrl/Meta + Backspace</td><td>delete item</td></tr>
<tr><td>Ctrl/Meta/Shift + U</td><td>upload new file/folder</td></tr>
<tr><td>Ctrl/Meta + M</td><td>create a new directory</td></tr>
<tr><td>Ctrl/Meta + X</td><td>cut selected path</td></tr>
<tr><td>Ctrl/Meta + V</td><td>paste previously selected paths to directory</td></tr>
<tr><td>Ctrl/Meta + Z</td><td>copy checksums of selected file</td></tr>
<tr><td>Ctrl + click</td><td>download selected item as archive</td></tr>
<tr><td>click file icon </td><td>rename item</td></tr>
<tr><td>double click file icon</td><td>delete item</td></tr>
<tr><td>drag-and-drop item on UI</td><td>move item</td></tr>
<tr><td>drag-and-drop external item</td><td>upload file/folders</td></tr>
<tr><td>any other letter</td><td>fuzzy search</td></tr>
</tbody></table></div>
<div onclick="window.sumsOff()" style="display: none;" id="sums"><table id="sumsTable"><tbody>
<tr><td>Key</td><td>Hash Algorithm</td></tr>
<tr><td>1</td><td>copy sha1 sum</td></tr>
<tr><td>2</td><td>copy sha256 sum</td></tr>
<tr><td>3</td><td>copy sha512 sum</td></tr>
<tr><td>5</td><td>copy md5 sum</td></tr>
</tbody></table></div>
<div style="display: none;" onclick="window.quitAll()" id="quitAll"><i style="display: none;" id="toast">cant reach server</i></div>
<textarea style="display: none;" id="text-editor"></textarea>
<div id="drop-grid"></div>
<input type="file" id="clickupload" multiple style="display:none"/>
<h1 onclick="return titleClick(event)">.{{.Title}}</h1>
<div id="icHolder">
{{if not .Ro}}
<div style="display:none;" onclick="document.getElementById('clickupload').click()" class="ic icon-large-upload manualUp"></div>
<div onclick="window.displayPad()" class="ic icon-large-pad" title="Create TXT file"></div>
<div class="ic icon-large-folder" onclick="window.mkdirBtn()" title="Create Folder"></div>
{{end}}
</div>
<div id="pics" style="display:none;"> <img draggable="false" onclick="window.picsNav()" id="picsHolder" /></div>
<div id="video" style="display:none;">
<div onclick="window.videodl()" class="icon-dl" id="video-dl"></div>
<video controls id="videoHolder"> </video>
</div>
<div id="pdf" style="display:none;"> </div>
<table id="linkTable">
{{range .RowsFolders}}
<tr>
<td class="iconRow"><i ondblclick="return rm(event)" onclick="return rename(event)" class="btn icon icon-{{.Ext}} icon-blank"></i></td>
<td class="file-size"><code>{{.Size}}</code></td>
<td class="arrow"><div class="arrow-icon"></div></td>
<td class="display-name"><a class="list-links" oncontextmenu="return setCursorTo(event.target.innerText)" onclick="return onClickLink(event)" href="{{.Href}}">{{.Name}}</a></td>
</tr>
{{end}}
{{range .RowsFiles}}
<tr>
<td class="iconRow"><i ondblclick="return rm(event)" onclick="return rename(event)" class="btn icon icon-{{.Ext}} icon-blank"></i></td>
<td class="file-size"><code>{{.Size}}</code></td>
<td class="arrow"><div class="arrow-icon"></div></td>
<td class="display-name"><a class="list-links" oncontextmenu="return setCursorTo(event.target.innerText)" onclick="return onClickLink(event)" href="{{.Href}}">{{.Name}}</a></td>
</tr>
{{end}}
</table>
<p id="help_message">Help: Ctrl/Cmd + h<p>
</body>
<div id="upBar" class="bar">
<span style="display: none;" class="barName" id="upBarName"></span>
<div style="display: none; background-color: green;" class="barPc" id="upBarPc">1%</div>
</div>
<div id="ok" class="notif icon-large-ok"></div>
<div id="sad" class="notif icon-large-sad-server"></div>
</html>