diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97a6b1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/marisa +/marisa-trash diff --git a/cmd/marisa/main.go b/cmd/marisa/main.go index 98e0b27..0f74d7e 100644 --- a/cmd/marisa/main.go +++ b/cmd/marisa/main.go @@ -1,27 +1,15 @@ 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" "marisa.chaotic.ninja/marisa" ) @@ -54,276 +42,6 @@ var conf struct { 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() - - 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 -} - -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 - } - - 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")) - } - } -} - -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 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) -} - -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) - } -} - -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 diff --git a/cmd/marisa/parseconfig.go b/cmd/marisa/parseconfig.go new file mode 100644 index 0000000..d08cd83 --- /dev/null +++ b/cmd/marisa/parseconfig.go @@ -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 +} diff --git a/cmd/marisa/servetemplate.go b/cmd/marisa/servetemplate.go new file mode 100644 index 0000000..f092f76 --- /dev/null +++ b/cmd/marisa/servetemplate.go @@ -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) + } +} diff --git a/cmd/marisa/uploader.go b/cmd/marisa/uploader.go new file mode 100644 index 0000000..f071449 --- /dev/null +++ b/cmd/marisa/uploader.go @@ -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) + } +} diff --git a/cmd/marisa/uploaderdelete.go b/cmd/marisa/uploaderdelete.go new file mode 100644 index 0000000..1369e6f --- /dev/null +++ b/cmd/marisa/uploaderdelete.go @@ -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) +} diff --git a/cmd/marisa/uploaderget.go b/cmd/marisa/uploaderget.go new file mode 100644 index 0000000..4b5defa --- /dev/null +++ b/cmd/marisa/uploaderget.go @@ -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) +} diff --git a/cmd/marisa/uploaderpost.go b/cmd/marisa/uploaderpost.go new file mode 100644 index 0000000..9ecb6ae --- /dev/null +++ b/cmd/marisa/uploaderpost.go @@ -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")) + } + } +} diff --git a/cmd/marisa/uploaderput.go b/cmd/marisa/uploaderput.go new file mode 100644 index 0000000..51723e5 --- /dev/null +++ b/cmd/marisa/uploaderput.go @@ -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")) +} diff --git a/cmd/marisa/usergroupids.go b/cmd/marisa/usergroupids.go new file mode 100644 index 0000000..758581c --- /dev/null +++ b/cmd/marisa/usergroupids.go @@ -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 +} diff --git a/cmd/marisa/writefile.go b/cmd/marisa/writefile.go new file mode 100644 index 0000000..d5367ec --- /dev/null +++ b/cmd/marisa/writefile.go @@ -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 +} diff --git a/cmd/marisa/writemeta.go b/cmd/marisa/writemeta.go new file mode 100644 index 0000000..c63d9c8 --- /dev/null +++ b/cmd/marisa/writemeta.go @@ -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 +} diff --git a/example/marisa.conf b/example/marisa.conf index fa93bef..334cf7d 100644 --- a/example/marisa.conf +++ b/example/marisa.conf @@ -1,36 +1,35 @@ -# TCP or unix Socket to listen on. -# When unix sockets are used, the content will be served over FastCGI. -#listen = /var/run/marisa-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 +[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 = daemon +# 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 is set, all path must be given according to the chroot. -# Note: the configuration file is read before chrooting. -#chroot = /var/www +# 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 different path used by the server. Must take into account -# the chroot if set. -rootdir = static -tmplpath = templates -filepath = files -metapath = meta +# 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/ +# filectx = /f/ # Maximum per-file upload size (in bytes) -maxsize = 536870912 # 512Mib +# maxsize = 536870912 # 512 MiB -# Default expiration time (in seconds). An expiration time of 0 seconds -# means no expiration. -expiry = 86400 # 24 hours +# Default expiration time (in seconds). +# An expiration time of 0 seconds means no expiration. +# expiry = 86400 # 24 hours diff --git a/example/static/dz.js b/example/static/dz.js deleted file mode 100644 index 34db10a..0000000 --- a/example/static/dz.js +++ /dev/null @@ -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 = `${link}`; - 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); -} diff --git a/example/static/favicon.ico b/example/static/favicon.ico new file mode 100644 index 0000000..9aed90e Binary files /dev/null and b/example/static/favicon.ico differ diff --git a/example/static/marisa.css b/example/static/marisa.css new file mode 100644 index 0000000..3aa05f0 --- /dev/null +++ b/example/static/marisa.css @@ -0,0 +1,17 @@ +body { + background-color: #282c37; + color: #f8f8f2; + font-family: sans-serif; +} +a { + color: #272822; +} +a:hover, a:link { + color: #e6db74; +} +a:visited { + color: #66d9ef; +} +table { + border-color: rgb(128, 0, 0); +} diff --git a/example/static/marisa.png b/example/static/marisa.png index df4444c..4636a72 100644 Binary files a/example/static/marisa.png and b/example/static/marisa.png differ diff --git a/example/static/marisa_98.css b/example/static/marisa_98.css deleted file mode 100644 index 7756464..0000000 --- a/example/static/marisa_98.css +++ /dev/null @@ -1,90 +0,0 @@ -body { - padding: 5%; - margin: auto; - max-width: 540px; - font-family: sans-serif; - font-size: 1.5rem; - text-align: center; - background-color: #550000; - color: #ff4444; -} - -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; - } -} diff --git a/example/templates/index.html b/example/templates/index.html index d4d7315..865e8e0 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -1,42 +1,47 @@ - + - - - + + + + - - Marisa -
- -

marisa

-
-
-
-
- - - -
-
- - -
-
-

File size limited to {{.Maxsize}}.

-
{{if .Links}} - - {{end}}
+ + + +
+

Marisa

+ + + +
+
+
+ + + +

+ File size limited to {{.Maxsize}}. +

+ + + {{if .Links}} + + {{range .Links}}{{end}} + + {{end}} +

+

© 2024 Izuru Yakumo

+ +
{{.}}