Compare commits

...

10 Commits

Author SHA1 Message Date
yakumo.izuru
5a46596dd6 Update manual and center the interface
Signed-off-by: Izuru Yakumo <yakumo.izuru@chaotic.ninja>

git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@68 d6811dac-2434-b64a-9ddc-f563ab233461
2024-02-11 02:33:59 +00:00
yakumo.izuru
c91851a592 リファクタリングと再設計
Signed-off-by: Izuru Yakumo <yakumo.izuru@chaotic.ninja>

git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@67 d6811dac-2434-b64a-9ddc-f563ab233461
2024-02-11 02:08:18 +00:00
yakumo.izuru
ba916d164e config.mk gone, update all manual pages
Signed-off-by: Izuru Yakumo <yakumo.izuru@chaotic.ninja>

git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@66 d6811dac-2434-b64a-9ddc-f563ab233461
2023-09-19 02:27:33 +00:00
yakumo.izuru
e30c19f621 M i m a s a m a ! ! !
Signed-off-by: Izuru Yakumo <yakumo.izuru@chaotic.ninja>

git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@65 d6811dac-2434-b64a-9ddc-f563ab233461
2023-09-18 21:19:59 +00:00
dev
981d705107 Add support for DELETE method
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@64 d6811dac-2434-b64a-9ddc-f563ab233461
2022-11-28 09:49:13 +00:00
dev
3efc95d373 Reformat source with go-fmt
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@63 d6811dac-2434-b64a-9ddc-f563ab233461
2022-11-28 09:47:47 +00:00
dev
20d125ed25 Merge branch 'master' of git.z3bra.org:partage
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@62 d6811dac-2434-b64a-9ddc-f563ab233461
2022-01-18 06:46:54 +00:00
contact
d4e4554830 Add support for "expiry" form setting
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@61 d6811dac-2434-b64a-9ddc-f563ab233461
2021-11-10 10:38:55 +00:00
contact
5f4f4e93db Provide man pages
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@60 d6811dac-2434-b64a-9ddc-f563ab233461
2021-11-03 23:09:30 +00:00
dev
e970f679d3 Add workaround for when removing the socket file is not possible
git-svn-id: https://svn.yakumo.dev/yakumo.izuru/marisa/trunk@59 d6811dac-2434-b64a-9ddc-f563ab233461
2022-01-18 06:46:16 +00:00
35 changed files with 854 additions and 719 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/marisa
/marisa-trash

14
COPYING Normal file
View File

@ -0,0 +1,14 @@
Copyright (c) 2021 Willy Goiffon <contact@z3bra.org>
Copyright (c) 2023-present Izuru Yakumo <postmaster@chaotic.ninja>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

13
LICENSE
View File

@ -1,13 +0,0 @@
Copyright (c) 2021 Willy Goiffon <contact@z3bra.org>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

1
LICENSE Symbolic link
View File

@ -0,0 +1 @@
COPYING

25
Makefile Normal file
View File

@ -0,0 +1,25 @@
GO ?= go
GOFLAGS ?= -v -ldflags "-w -X `go list`.Version=${VERSION} -X `go list`.Commit=${COMMIT} -X `go list`.Build=${BUILD}"
CGO ?= 0
VERSION = `git describe --abbrev=0 --tags 2>/dev/null || echo "$VERSION"`
COMMIT = `git rev-parse --short HEAD || echo "$COMMIT"`
BRANCH = `git rev-parse --abbrev-ref HEAD`
BUILD = `git show -s --pretty=format:%cI`
PREFIX ?= /usr/local
all: marisa marisa-trash
marisa:
CGO_ENABLED=${CGO} go build ${GOFLAGS} ./cmd/marisa
marisa-trash:
CGO_ENABLED=${CGO} go build ${GOFLAGS} ./cmd/marisa-trash
clean:
rm -f marisa marisa-trash
install:
install -Dm0755 marisa ${PREFIX}/bin/marisa
install -Dm0755 marisa-trash ${PREFIX}/bin/marisa-trash
install -Dm0644 marisa.1 ${PREFIX}/share/man/man1/marisa.1
install -Dm0644 marisa.conf.5 ${PREFIX}/share/man/man5/marisa.conf.5
.PHONY: marisa marisa-trash

View File

@ -1,9 +1,7 @@
partage
=======
marisa
======
HTTP based File upload system.
![screenshot](https://z3bra.org/partage/screenshot.png)
Features
--------
+ Link expiration
@ -17,11 +15,11 @@ Features
Usage
-----
Refer to the partage(1) manual page for details and examples.
Refer to the marisa(1) manual page for details and examples.
partage [-v] [-f partage.conf]
marisa [-v] [-f marisa.conf]
Configuration is done through its configuration file, partage.conf(5).
Configuration is done through its configuration file, marisa.conf(5).
The format is that of the INI file format.
Uploading files is done via PUT and POST requests. Multiple files can
@ -34,5 +32,5 @@ Installation
------------
Edit the `config.mk` file to match your setup, then run the following:
$ mk
# mk install
$ (b)make
# (b)make install

141
cmd/marisa/main.go Normal file
View File

@ -0,0 +1,141 @@
package main
import (
"flag"
"log"
"net"
"net/http"
"net/http/fcgi"
"os"
"os/signal"
"syscall"
"marisa.chaotic.ninja/marisa"
)
type templatedata struct {
Links []string
Size string
Maxsize string
}
type metadata struct {
Filename string
Size int64
Expiry int64
}
var conf struct {
user string
group string
chroot string
listen string
baseuri string
rootdir string
tmplpath string
filepath string
metapath string
filectx string
maxsize int64
expiry int64
}
var verbose bool
func main() {
var err error
var configfile string
var listener net.Listener
/* default values */
conf.listen = "127.0.0.1:8080"
conf.baseuri = "http://127.0.0.1:8080"
conf.rootdir = "static"
conf.tmplpath = "templates"
conf.filepath = "files"
conf.metapath = "meta"
conf.filectx = "/f/"
conf.maxsize = 34359738368
conf.expiry = 86400
flag.StringVar(&configfile, "f", "", "Configuration file")
flag.BoolVar(&verbose, "v", false, "Verbose logging")
flag.Parse()
if configfile != "" {
if verbose {
log.Printf("Reading configuration %s", configfile)
}
parseconfig(configfile)
}
if conf.chroot != "" {
if verbose {
log.Printf("Changing root to %s", conf.chroot)
}
syscall.Chroot(conf.chroot)
}
if conf.listen[0] == '/' {
/* Remove any stale socket */
os.Remove(conf.listen)
if listener, err = net.Listen("unix", conf.listen); err != nil {
log.Fatal(err)
}
defer listener.Close()
/*
* Ensure unix socket is removed on exit.
* Note: this might not work when dropping privileges
*/
defer os.Remove(conf.listen)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt, os.Kill, syscall.SIGTERM)
go func() {
_ = <-sigs
listener.Close()
if err = os.Remove(conf.listen); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
} else {
if listener, err = net.Listen("tcp", conf.listen); err != nil {
log.Fatal(err)
}
defer listener.Close()
}
if conf.user != "" {
if verbose {
log.Printf("Dropping privileges to %s", conf.user)
}
uid, gid, err := usergroupids(conf.user, conf.group)
if err != nil {
log.Fatal(err)
}
if listener.Addr().Network() == "unix" {
os.Chown(conf.listen, uid, gid)
}
syscall.Setuid(uid)
syscall.Setgid(gid)
}
http.HandleFunc("/", uploader)
http.Handle(conf.filectx, http.StripPrefix(conf.filectx, http.FileServer(http.Dir(conf.filepath))))
if verbose {
log.Printf("Starting marisa %v\n", marisa.FullVersion())
log.Printf("Listening on %s", conf.listen)
}
if listener.Addr().Network() == "unix" {
err = fcgi.Serve(listener, nil)
log.Fatal(err) /* NOTREACHED */
}
err = http.Serve(listener, nil)
log.Fatal(err) /* NOTREACHED */
}

27
cmd/marisa/parseconfig.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"gopkg.in/ini.v1"
)
func parseconfig(file string) error {
cfg, err := ini.Load(file)
if err != nil {
return err
}
conf.listen = cfg.Section("marisa").Key("listen").String()
conf.user = cfg.Section("marisa").Key("user").String()
conf.group = cfg.Section("marisa").Key("group").String()
conf.baseuri = cfg.Section("www").Key("baseuri").String()
conf.filepath = cfg.Section("www").Key("filepath").String()
conf.metapath = cfg.Section("www").Key("metapath").String()
conf.filectx = cfg.Section("www").Key("filectx").String()
conf.rootdir = cfg.Section("www").Key("rootdir").String()
conf.chroot = cfg.Section("marisa").Key("chroot").String()
conf.tmplpath = cfg.Section("www").Key("tmplpath").String()
conf.maxsize, _ = cfg.Section("www").Key("maxsize").Int64()
conf.expiry, _ = cfg.Section("www").Key("expiry").Int64()
return nil
}

View File

@ -0,0 +1,25 @@
package main
import (
"fmt"
"html/template"
"log"
"net/http"
)
func servetemplate(w http.ResponseWriter, f string, d templatedata) {
t, err := template.ParseFiles(conf.tmplpath + "/" + f)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if verbose {
log.Printf("Serving template %s", t.Name())
}
err = t.Execute(w, d)
if err != nil {
fmt.Println(err)
}
}

23
cmd/marisa/uploader.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"log"
"net/http"
)
func uploader(w http.ResponseWriter, r *http.Request) {
if verbose {
log.Printf("%s: <%s> %s %s %s", r.Host, r.RemoteAddr, r.Method, r.RequestURI, r.Proto)
}
switch r.Method {
case "DELETE":
uploaderDelete(w, r)
case "POST":
uploaderPost(w, r)
case "PUT":
uploaderPut(w, r)
case "GET":
uploaderGet(w, r)
}
}

View File

@ -0,0 +1,28 @@
package main
import (
"log"
"net/http"
"os"
)
func uploaderDelete(w http.ResponseWriter, r *http.Request) {
// r.URL.Path is sanitized regarding "." and ".."
filename := r.URL.Path
filepath := conf.filepath + filename
if verbose {
log.Printf("Deleting file %s", filepath)
}
f, err := os.Open(filepath)
if err != nil {
http.NotFound(w, r)
return
}
f.Close()
// Force file expiration
writemeta(filepath, 0)
w.WriteHeader(http.StatusNoContent)
}

24
cmd/marisa/uploaderget.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
"log"
"net/http"
"github.com/dustin/go-humanize"
)
func uploaderGet(w http.ResponseWriter, r *http.Request) {
// r.URL.Path is sanitized regarding "." and ".."
filename := r.URL.Path
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
data := templatedata{Maxsize: humanize.IBytes(uint64(conf.maxsize))}
servetemplate(w, "/index.html", data)
return
}
if verbose {
log.Printf("Serving file %s", conf.rootdir+filename)
}
http.ServeFile(w, r, conf.rootdir+filename)
}

View File

@ -0,0 +1,71 @@
package main
import (
"encoding/json"
"io/ioutil"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"github.com/dustin/go-humanize"
)
func uploaderPost(w http.ResponseWriter, r *http.Request) {
/* read 32Mb at a time */
r.ParseMultipartForm(32 << 20)
links := []string{}
for _, h := range r.MultipartForm.File["file"] {
if h.Size > conf.maxsize {
http.Error(w, "File is too big", http.StatusRequestEntityTooLarge)
return
}
post, err := h.Open()
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer post.Close()
tmp, _ := ioutil.TempFile(conf.filepath, "*"+path.Ext(h.Filename))
f, err := os.Create(tmp.Name())
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer f.Close()
if err = writefile(f, post, h.Size); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
defer os.Remove(tmp.Name())
return
}
expiry, err := strconv.Atoi(r.PostFormValue("expiry"))
if err != nil || expiry < 0 {
expiry = int(conf.expiry)
}
writemeta(tmp.Name(), int64(expiry))
link := conf.baseuri + conf.filectx + filepath.Base(tmp.Name())
links = append(links, link)
}
switch r.PostFormValue("output") {
case "html":
data := templatedata{
Maxsize: humanize.IBytes(uint64(conf.maxsize)),
Links: links,
}
servetemplate(w, "/index.html", data)
case "json":
data, _ := json.Marshal(links)
w.Write(data)
default:
for _, link := range links {
w.Write([]byte(link + "\r\n"))
}
}
}

40
cmd/marisa/uploaderput.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"path/filepath"
)
func uploaderPut(w http.ResponseWriter, r *http.Request) {
/* limit upload size */
if r.ContentLength > conf.maxsize {
http.Error(w, "File is too big", http.StatusRequestEntityTooLarge)
}
tmp, _ := ioutil.TempFile(conf.filepath, "*"+path.Ext(r.URL.Path))
f, err := os.Create(tmp.Name())
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
if verbose {
log.Printf("Writing %d bytes to %s", r.ContentLength, tmp.Name())
}
if err = writefile(f, r.Body, r.ContentLength); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
defer os.Remove(tmp.Name())
return
}
writemeta(tmp.Name(), conf.expiry)
resp := conf.baseuri + conf.filectx + filepath.Base(tmp.Name())
w.Write([]byte(resp + "\r\n"))
}

View File

@ -0,0 +1,26 @@
package main
import (
"os/user"
"strconv"
)
func usergroupids(username string, groupname string) (int, int, error) {
u, err := user.Lookup(username)
if err != nil {
return -1, -1, err
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
if conf.group != "" {
g, err := user.LookupGroup(groupname)
if err != nil {
return uid, -1, err
}
gid, _ = strconv.Atoi(g.Gid)
}
return uid, gid, nil
}

38
cmd/marisa/writefile.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"io"
"os"
)
func writefile(f *os.File, s io.ReadCloser, contentlength int64) error {
buffer := make([]byte, 4096)
eof := false
sz := int64(0)
defer f.Sync()
for !eof {
n, err := s.Read(buffer)
if err != nil && err != io.EOF {
return err
} else if err == io.EOF {
eof = true
}
/* ensure we don't write more than expected */
r := int64(n)
if sz+r > contentlength {
r = contentlength - sz
eof = true
}
_, err = f.Write(buffer[:r])
if err != nil {
return err
}
sz += r
}
return nil
}

46
cmd/marisa/writemeta.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"log"
"os"
"path/filepath"
"time"
)
func writemeta(filename string, expiry int64) error {
f, _ := os.Open(filename)
stat, _ := f.Stat()
size := stat.Size()
f.Close()
if expiry < 0 {
expiry = conf.expiry
}
meta := metadata{
Filename: filepath.Base(filename),
Size: size,
Expiry: time.Now().Unix() + expiry,
}
if verbose {
log.Printf("Saving metadata for %s in %s", meta.Filename, conf.metapath+"/"+meta.Filename+".json")
}
f, err := os.Create(conf.metapath + "/" + meta.Filename + ".json")
if err != nil {
return err
}
defer f.Close()
j, err := json.Marshal(meta)
if err != nil {
return err
}
_, err = f.Write(j)
return err
}

View File

@ -1,2 +0,0 @@
GO = go
GOOS = `{uname -s | tr A-Z a-z}

35
example/marisa.conf Normal file
View File

@ -0,0 +1,35 @@
[marisa]
# TCP or Unix socket to listen on.
# When the Unix socket is used, the content will be served through FastCGI
# listen = /var/run/marisa.sock
# listen = 127.0.0.1:9000
# Drop privilege to the user and group specified.
# When only the user is specified, the default group of the user
# will be used.
# user = www
# group = www
# Change the root directory to the following directory.
# When a chroot(2) is set, all paths must be given according to it.
# Note: the configuration file is read before it happens
# chroot =
[www]
# baseuri = http://127.0.0.1:9000
# Path to the resources used by the server, must take into account
# the chroot is set
# rootdir = ./static
# tmplpath = ./templates
# filepath = ./files
# metapath = ./meta
# URI context that files will be served on
# filectx = /f/
# Maximum per-file upload size (in bytes)
# maxsize = 536870912 # 512 MiB
# Default expiration time (in seconds).
# An expiration time of 0 seconds means no expiration.
# expiry = 86400 # 24 hours

View File

@ -1,36 +0,0 @@
# TCP or unix Socket to listen on.
# When unix sockets are used, the content will be served over FastCGI.
#listen = /var/run/partage-fcgi.sock
listen = 127.0.0.1:9000
# Base to use when constructing URI to files uploaded.
# The full URI must be specified, in the form SCHEME://HOST[:PORT]
baseuri = http://127.0.0.1:9000
# Drop privilege to the user and group specified.
# When only the user is specified, the default group of the user will
# be used.
#user = www
#group = daemon
# Change the root directory to the following directory.
# When a chroot is set, all path must be given according to the chroot.
# Note: the configuration file is read before chrooting.
#chroot = /var/www
# Path to the different path used by the server. Must take into account
# the chroot if set.
rootdir = example/static
tmplpath = example/templates
filepath = example/files
metapath = example/meta
# URI context that files will be served on
filectx = /f/
# Maximum per-file upload size (in bytes)
maxsize = 536870912 # 512Mib
# Default expiration time (in seconds). An expiration time of 0 seconds
# means no expiration.
expiry = 86400 # 24 hours

View File

@ -1,98 +0,0 @@
// Handle drag and drop into a dropzone_element div:
// send the files as a POST request to the server
"use strict";
// Only start once the DOM tree is ready
if(document.readyState === "complete") {
setupzone();
} else {
document.addEventListener("DOMContentLoaded", setupzone);
}
function setupzone() {
let dropzone = document.getElementById("dropzone");
let fileinput = document.getElementById("filebox");
let fallbackform = document.getElementById("fallbackform");
fallbackform.style.display = "none";
dropzone.className = "dropzone";
dropzone.innerHTML = "Click or drop file(s)";
dropzone.onclick = function() {
fileinput.click()
return false;
}
dropzone.ondragover = function() {
this.className = "dropzone dragover";
return false;
}
dropzone.ondragleave = function() {
this.className = "dropzone";
return false;
}
dropzone.ondrop = function(e) {
// Stop browser from simply opening that was just dropped
e.preventDefault();
// Restore original dropzone appearance
this.className = "dropzone";
sendfiles(e.dataTransfer.files)
}
fileinput.onchange = function(e) {
sendfiles(this.files)
}
}
function sendfiles(files) {
let uploads = document.getElementById("uploads");
let progressbar = document.createElement("progress");
let uploadlist = document.createElement("ul");
let uploadtext = document.createElement("textarea");
let formData = new FormData(), xhr = new XMLHttpRequest();
// used for clipboard only
uploadtext.style.display = "none";
uploads.appendChild(progressbar);
uploads.appendChild(uploadlist);
uploads.appendChild(uploadtext);
formData.append("expiry", 10);
for(let i=0; i < files.length; i++) {
formData.append("file", files[i]);
}
// triggers periodically
xhr.upload.onprogress = function(e) {
// e.loaded - how many bytes downloaded
// e.lengthComputable = true if the server sent Content-Length header
// e.total - total number of bytes (if lengthComputable)
}
xhr.onreadystatechange = function() {
if(xhr.readyState === XMLHttpRequest.DONE) {
progressbar.remove();
this.response.split(/\r?\n/).forEach(function(link) {
let li = document.createElement("li");
li.innerHTML = `<a href="${link}">${link}</a>`;
uploadlist.appendChild(li);
});
let clippy = document.createElement("button");
uploads.appendChild(clippy);
clippy.innerText = " 📋 copy ";
clippy.onclick = function(e) {
uploadtext.select();
document.execCommand("copy");
}
}
}
xhr.open('POST', window.location.href, true); // async = true
xhr.send(formData);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 46 KiB

15
example/static/marisa.css Normal file
View File

@ -0,0 +1,15 @@
body {
background-color: #282c37;
color: #f8f8f2;
font-family: sans-serif;
text-align: center;
}
a {
color: #272822;
}
a:hover, a:link {
color: #e6db74;
}
a:visited {
color: #66d9ef;
}

BIN
example/static/marisa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,106 +0,0 @@
body {
padding: 5%;
margin: auto;
max-width: 540px;
font-family: serif;
font-size: 1.5rem;
text-align: center;
background-color: #eeeeee;
color: #222222;
}
header {
display: flex;
flex-direction: column;
flex-wrap: wrap-reverse;
align-items: center;
align-content: center;
}
section {
display: flex;
justify-content: flex-end;
font-size: initial;
}
section#formsettings > * {
margin-top: 20px;
margin-left: 20px;
}
img#logo {
height: 100%;
max-height: 30vh;
}
h1 {
font-size: 4.0rem;
}
#uploads {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#uploads > ul {
list-style: none;
text-align: left;
padding: 0;
}
#uploads > button {
align-self: flex-end;
margin-right: 10%;
}
.dropzone {
padding-top: 60px;
padding-bottom: 60px;
border: 2px dashed #888888;
border-radius: 8px;
text-align: center;
margin: auto;
color: #888888;
}
.dropzone.dragover {
color: #222222;
border-color: #222222;
}
/* font attributes are not inherited by default */
input, input::file-selector-button {
text-align: inherit;
font-family: inherit;
font-size: inherit;
}
@media (min-aspect-ratio: 18/9) {
header {
flex-direction: row;
}
h1 { font-size: 3rem; margin-right: 10px; }
img#logo {
height: 50%;
max-height: 20vh;
order: 2;
}
}
@media (prefers-color-scheme: light) {
a { color: black; }
body {
color: #222222;
background-color: #eeeeee;
}
}
@media (prefers-color-scheme: dark) {
a { color: white; }
body {
color: #eeeeee;
background-color: #222222;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,42 +1,45 @@
<!DOCTYPE HTML>
<!DOCTYPE HTML PUBLIC "//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="utf-8">
<meta name="author" content="z3bra">
<meta name="robots" content="noindex,nofollow" />
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" type="text/css" href="/partage.css" />
<link rel="icon" type="image/ico" href="/favicon.ico" />
<title>Partage</title>
</head>
<body>
<header>
<img id="logo" src="/partage.png" />
<h1>partage</h1>
</header>
<form enctype="multipart/form-data" method="post">
<div id="dropzone"></div>
<div id="fallbackform" class="dropzone">
<input id="filebox" name="file" type="file" multiple/>
<input id="output" name="output" type="hidden" value='html' />
<input type="submit" value="Upload"/>
</div>
<section id="formsettings">
<label for="expiry"> Destroy after </label>
<select id="expiry" name="expiry">
<option value="900"> 15 minutes </option>
<option value="3600"> 1 hour </option>
<option value="28800"> 8 hours </option>
<option value="86400" selected> 1 day </option>
<option value="604800"> 1 week </option>
</select>
</section>
<head>
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="/marisa.css">
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="author" content="Izuru Yakumo">
<meta name="viewport" content="width=device-width">
<title>Marisa</title>
</head>
<body>
<table>
<thead>
<img class="logo" src="/marisa.png">
<br>
<h1>Marisa</h1>
</thead>
<tbody>
<form enctype="multipart/form-data" method="POST">
<input class="file" name="file" type="file"><br>
<input name="output" type="hidden" value="html"><br>
<input type="submit"><br>
<label for="expiry">Destroy after</label>
<select name="expiry">
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="28800">8 hours</option>
<option value="86400">1 day</option>
<option value="604800">1 week</option>
</select>
</form>
<p>File size limited to {{.Maxsize}}.</p>
<div id="uploads">{{if .Links}}
<ul>
{{range .Links}}<li><a href="{{.}}">{{.}}</a></li>{{end}}
</ul>
{{end}}</div>
</body>
<p>
File size limited to {{.Maxsize}}.
</p>
</tbody>
<tfoot>
{{if .Links}}
<tr>
{{range .Links}}<td><a href="{{.}}">{{.}}</a></td>{{end}}
</tr>
{{end}}
</tfoot>
</table>
</body>
</html>

9
go.mod
View File

@ -1,9 +1,10 @@
module git.z3bra.org/partage
module marisa.chaotic.ninja/marisa
go 1.17
require (
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de // indirect
gopkg.in/ini.v1 v1.63.2 // indirect
github.com/dustin/go-humanize v1.0.0
gopkg.in/ini.v1 v1.63.2
)
require github.com/stretchr/testify v1.8.4 // indirect

20
go.sum Normal file
View File

@ -0,0 +1,20 @@
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/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.63.2 h1:tGK/CyBg7SMzb60vP1M03vNZ3VDu3wGQJwn7Sxi9r3c=
gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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=

44
marisa-trash.1 Normal file
View File

@ -0,0 +1,44 @@
.Dd $Mdocdate$
.Dt MARISA-TRASH 1
.Os
.Sh NAME
.Nm marisa-trash
.Nd Purge expired share files
.Sh SYNOPSIS
.Nm marisa-trash
.Op Fl v
.Op Fl f Ar files
.Op Fl m Ar metadata
.Sh DESCRIPTION
Upon each run,
.Nm
will check expiration times for files in the
.Pa metadata
directory, and delete the according file in the
.Pa files
directory if the expiration time has passed.
.Pp
.Nm
is best run as a
.Xr cron 8
job, as the same user as the
.Xr marisa 1
daemon.
.Bl -tag -width Ds
.It Fl v
Turn on verbose logging to
.Pa stderr
.It Fl f Ar files
Set the location of actual files to
.Pa files
.It Fl m Ar metadata
Lookup metadata files in directory
.Pa metadata
.El
.Sh SEE ALSO
.Xr marisa 1
.Sh AUTHOR
.An Willy Goiffon Aq Mt dev@z3bra.org
.Pp
"Borrowed" by
.An Izuru Yakumo Aq Mt yakumo.izuru@chaotic.ninja

43
marisa.1 Normal file
View File

@ -0,0 +1,43 @@
.Dd $Mdocdate$
.Dt MARISA 1
.Os
.Sh NAME
.Nm marisa
.Nd HTTP based file upload system
.Sh SYNOPSIS
.Nm marisa
.Op Fl v
.Op Fl f Ar file
.Sh DESCRIPTION
.Nm
is an HTTP server that permits temporary file uploads using PUT and
POST requests.
.Pp
Files uploaded are saved in a single directory and given random names
while retaining their original extension.
A configurable expiration time is set for each file, that can be used
to cleanup expired files thanks to
.Xr marisa-trash 1 .
.Bl -tag -width Ds
.It Fl v
Turn on verbose logging to
.Pa stderr
.It Fl f Ar file
Load configuration from
.Pa file
.El
.Sh SEE ALSO
.Xr marisa-trash 1 ,
.Xr marisa.conf 5
.Sh AUTHORS
.An Willy Goiffon Aq Mt dev@z3bra.org
.Pp
"Borrowed" by
.An Izuru Yakumo Aq Mt yakumo.izuru@chaotic.ninja
.Sh BUGS
If you upload a file through the browser, and refresh the
page, the file will get constantly reuploaded, which may
exhaust the server's storage at some point.
.Pp
This shouldn't happen with a CLI, such as
.Xr curl 1

94
marisa.conf.5 Normal file
View File

@ -0,0 +1,94 @@
.Dd $Mdocdate$
.Dt MARISA.CONF 5
.Os
.Sh NAME
.Nm marisa.conf
.Nd marisa configuration file format
.Sh DESCRIPTION
.Nm
is the configuration file for the HTTP file sharing system,
.Xr marisa 1 .
.Sh CONFIGURATION
Here are the settings that can be set:
.Bl -tag -width Ds
.It Ic listen Ar socket
Have the program listen on
.Ar socket .
This socket can be specified either as a TCP socket:
.Ar host:port
or as a Unix socket:
.Ar /path/to/marisa.sock .
When using Unix sockets, the program will serve content using the
.Em FastCGI
protocol.
.It Ic user Ar user
Username that the program will drop privileges to upon startup. When
using Unix sockets, the owner of the socket will be changed to this user.
.It Ic group Ar group
Group that the program will drop privileges to upon startup (require that
.Ic user
is set). When using Unix sockets, the owner group of the socket will be
changed to this group.
.It Ic chroot Pa dir
Directory to chroot into upon startup. When specified, all other path
must be set within the chroot directory.
.It Ic baseuri Ar uri
Base URI to use when constructing hyper links.
.It Ic rootdir Pa dir
Directory containing static files.
.It Ic tmplpath Pa dir
Directory containing template files.
.It Ic filepath Pa dir
Directory where uploaded files must be written to.
.It Ic metapath Pa dir
Directory where metadata for uploaded files will be saved.
.It Ic filectx Pa context
URI context to use for serving files.
.It Ic maxsize Ar size
Maximum size per file to accept for uploads.
.It Ic expiry Ar time
Default expiration time to set for uploads.
.El
.Sh EXAMPLE
Configuration suitable for use with
.Xr httpd 8
using fastcgi:
.Bd -literal -offset indent
listen = /run/marisa.sock
baseuri = https://domain.tld
user = www
group = daemon
chroot = /var/www
rootdir = /htdocs/static
filepath = /htdocs/files
metapath = /htdocs/meta
tmplpath = /htdocs/templates
filectx = /d/
maxsize = 10737418240 # 10 Gib
expiry = 86400 # 24 hours
.Ed
Mathing
.Xr httpd.conf 5
configuration:
.Bd -literal -offset indent
server "domain.tld" {
listen on * tls port 443
connection { max request body 10737418240 }
location "*" {
fastcgi socket "/run/marisa.sock"
}
}
types { include "/usr/share/misc/mime.types" }
.Ed
.Sh SEE ALSO
.Xr marisa 1 ,
.Xr marisa-trash 1 ,
.Xr httpd 8,
.Xr httpd.conf 5
.Sh AUTHORS
.An Willy Goiffon Aq Mt dev@z3bra.org
.Pp
"Borrowed" by
.An Izuru Yakumo Aq Mt yakumo.izuru@chaotic.ninja

30
mkfile
View File

@ -1,30 +0,0 @@
<config.mk
all:V: partage partage-trash/partage-trash
%: %.go
$GO build -o $stem $stem.go
clean:V:
rm -f partage partage-trash/partage-trash
install:V: partage partage-trash/partage-trash
mkdir -p ${DESTDIR}${PREFIX}/bin
cp partage ${DESTDIR}${PREFIX}/bin/partage
cp partage-trash/partage-trash ${DESTDIR}${PREFIX}/bin/partage-trash
chmod 755 ${DESTDIR}${PREFIX}/bin/partage
chmod 755 ${DESTDIR}${PREFIX}/bin/partage-trash
mkdir -p ${DESTDIR}${MANDIR}/man1
cp man/partage.1 ${DESTDIR}${MANDIR}/man1/partage.1
cp man/partage-trash.1 ${DESTDIR}${MANDIR}/man1/partage-trash.1
cp man/partage.conf.5 ${DESTDIR}${MANDIR}/man5/partage.conf.5
chmod 644 ${DESTDIR}${MANDIR}/man1/partage.1
chmod 644 ${DESTDIR}${MANDIR}/man1/partage-trash.1
chmod 644 ${DESTDIR}${MANDIR}/man5/partage.conf.5
uninstall:V:
rm ${DESTDIR}${PREFIX}/bin/partage
rm ${DESTDIR}${PREFIX}/bin/partage-trash
rm ${DESTDIR}${MANDIR}/man1/partage.1
rm ${DESTDIR}${MANDIR}/man1/partage-trash.1
rm ${DESTDIR}${MANDIR}/man5/partage.conf.5

View File

@ -1,382 +0,0 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/http/fcgi"
"os"
"os/signal"
"os/user"
"path"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/dustin/go-humanize"
"gopkg.in/ini.v1"
)
type templatedata struct {
Links []string
Size string
Maxsize string
}
type metadata struct {
Filename string
Size int64
Expiry int64
}
var conf struct {
user string
group string
chroot string
listen string
baseuri string
rootdir string
tmplpath string
filepath string
metapath string
filectx string
maxsize int64
expiry int64
}
var verbose bool
func writefile(f *os.File, s io.ReadCloser, contentlength int64) error {
buffer := make([]byte, 4096)
eof := false
sz := int64(0)
defer f.Sync()
for !eof {
n, err := s.Read(buffer)
if err != nil && err != io.EOF {
return err
} else if err == io.EOF {
eof = true
}
/* ensure we don't write more than expected */
r := int64(n)
if sz+r > contentlength {
r = contentlength - sz
eof = true
}
_, err = f.Write(buffer[:r])
if err != nil {
return err
}
sz += r
}
return nil
}
func writemeta(filename string, expiry int64) error {
f, _ := os.Open(filename)
stat, _ := f.Stat()
size := stat.Size()
f.Close()
meta := metadata{
Filename: filepath.Base(filename),
Size: size,
Expiry: time.Now().Unix() + expiry,
}
if verbose {
log.Printf("Saving metadata for %s in %s", meta.Filename, conf.metapath+"/"+meta.Filename+".json")
}
f, err := os.Create(conf.metapath + "/" + meta.Filename + ".json")
if err != nil {
return err
}
defer f.Close()
j, err := json.Marshal(meta)
if err != nil {
return err
}
_, err = f.Write(j)
return err
}
func servetemplate(w http.ResponseWriter, f string, d templatedata) {
t, err := template.ParseFiles(conf.tmplpath + "/" + f)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if verbose {
log.Printf("Serving template %s", t.Name())
}
err = t.Execute(w, d)
if err != nil {
fmt.Println(err)
}
}
func uploaderPut(w http.ResponseWriter, r *http.Request) {
/* limit upload size */
if r.ContentLength > conf.maxsize {
http.Error(w, "File is too big", http.StatusRequestEntityTooLarge)
}
tmp, _ := ioutil.TempFile(conf.filepath, "*"+path.Ext(r.URL.Path))
f, err := os.Create(tmp.Name())
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
if verbose {
log.Printf("Writing %d bytes to %s", r.ContentLength, tmp.Name())
}
if err = writefile(f, r.Body, r.ContentLength); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
defer os.Remove(tmp.Name())
return
}
writemeta(tmp.Name(), conf.expiry)
resp := conf.baseuri + conf.filectx + filepath.Base(tmp.Name())
w.Write([]byte(resp + "\r\n"))
}
func uploaderPost(w http.ResponseWriter, r *http.Request) {
/* read 32Mb at a time */
r.ParseMultipartForm(32 << 20)
links := []string{}
for _, h := range r.MultipartForm.File["file"] {
if h.Size > conf.maxsize {
http.Error(w, "File is too big", http.StatusRequestEntityTooLarge)
return
}
post, err := h.Open()
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer post.Close()
tmp, _ := ioutil.TempFile(conf.filepath, "*"+path.Ext(h.Filename))
f, err := os.Create(tmp.Name())
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
defer f.Close()
if err = writefile(f, post, h.Size); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
defer os.Remove(tmp.Name())
return
}
writemeta(tmp.Name(), conf.expiry)
link := conf.baseuri + conf.filectx + filepath.Base(tmp.Name())
links = append(links, link)
}
switch r.PostFormValue("output") {
case "html":
data := templatedata{
Maxsize: humanize.IBytes(uint64(conf.maxsize)),
Links: links,
}
servetemplate(w, "/index.html", data)
case "json":
data, _ := json.Marshal(links)
w.Write(data)
default:
for _, link := range links {
w.Write([]byte(link + "\r\n"))
}
}
}
func uploaderGet(w http.ResponseWriter, r *http.Request) {
// r.URL.Path is sanitized regarding "." and ".."
filename := r.URL.Path
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
data := templatedata{Maxsize: humanize.IBytes(uint64(conf.maxsize))}
servetemplate(w, "/index.html", data)
return
}
if verbose {
log.Printf("Serving file %s", conf.rootdir+filename)
}
http.ServeFile(w, r, conf.rootdir+filename)
}
func uploader(w http.ResponseWriter, r *http.Request) {
if verbose {
log.Printf("%s: <%s> %s %s %s", r.Host, r.RemoteAddr, r.Method, r.RequestURI, r.Proto)
}
switch r.Method {
case "POST":
uploaderPost(w, r)
case "PUT":
uploaderPut(w, r)
case "GET":
uploaderGet(w, r)
}
}
func parseconfig(file string) error {
cfg, err := ini.Load(file)
if err != nil {
return err
}
conf.listen = cfg.Section("").Key("listen").String()
conf.user = cfg.Section("").Key("user").String()
conf.group = cfg.Section("").Key("group").String()
conf.baseuri = cfg.Section("").Key("baseuri").String()
conf.filepath = cfg.Section("").Key("filepath").String()
conf.metapath = cfg.Section("").Key("metapath").String()
conf.filectx = cfg.Section("").Key("filectx").String()
conf.rootdir = cfg.Section("").Key("rootdir").String()
conf.chroot = cfg.Section("").Key("chroot").String()
conf.tmplpath = cfg.Section("").Key("tmplpath").String()
conf.maxsize, _ = cfg.Section("").Key("maxsize").Int64()
conf.expiry, _ = cfg.Section("").Key("expiry").Int64()
return nil
}
func usergroupids(username string, groupname string) (int, int, error) {
u, err := user.Lookup(username)
if err != nil {
return -1, -1, err
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
if conf.group != "" {
g, err := user.LookupGroup(groupname)
if err != nil {
return uid, -1, err
}
gid, _ = strconv.Atoi(g.Gid)
}
return uid, gid, nil
}
func main() {
var err error
var configfile string
var listener net.Listener
/* default values */
conf.listen = "0.0.0.0:8080"
conf.baseuri = "http://127.0.0.1:8080"
conf.rootdir = "static"
conf.tmplpath = "templates"
conf.filepath = "files"
conf.metapath = "meta"
conf.filectx = "/f/"
conf.maxsize = 34359738368
conf.expiry = 86400
flag.StringVar(&configfile, "f", "", "Configuration file")
flag.BoolVar(&verbose, "v", false, "Verbose logging")
flag.Parse()
if configfile != "" {
if verbose {
log.Printf("Reading configuration %s", configfile)
}
parseconfig(configfile)
}
if conf.chroot != "" {
if verbose {
log.Printf("Changing root to %s", conf.chroot)
}
syscall.Chroot(conf.chroot)
}
if conf.listen[0] == '/' {
listener, err = net.Listen("unix", conf.listen)
if err != nil {
log.Fatal(err)
}
/* Ensure unix socket is removed on exit */
defer os.Remove(conf.listen)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
_ = <-sigs
os.Remove(conf.listen)
os.Exit(0)
}()
} else {
listener, err = net.Listen("tcp", conf.listen)
if err != nil {
log.Fatal(err)
}
}
if conf.user != "" {
if verbose {
log.Printf("Dropping privileges to %s", conf.user)
}
uid, gid, err := usergroupids(conf.user, conf.group)
if err != nil {
log.Fatal(err)
}
if listener.Addr().Network() == "unix" {
os.Chown(conf.listen, uid, gid)
}
syscall.Setuid(uid)
syscall.Setgid(gid)
}
http.HandleFunc("/", uploader)
http.Handle(conf.filectx, http.StripPrefix(conf.filectx, http.FileServer(http.Dir(conf.filepath))))
if verbose {
log.Printf("Listening on %s", conf.listen)
}
if listener.Addr().Network() == "unix" {
err = fcgi.Serve(listener, nil)
log.Fatal(err) /* NOTREACHED */
}
err = http.Serve(listener, nil)
log.Fatal(err) /* NOTREACHED */
}

18
version.go Normal file
View File

@ -0,0 +1,18 @@
package marisa
import (
"fmt"
)
var (
// Version release version
Version = "0.0.1"
// Commit will be overwritten automatically by the build system
Commit = "HEAD"
)
// FullVersion display the full version and build
func FullVersion() string {
return fmt.Sprintf("%s@%s", Version, Commit)
}