commit b78866f8bd095f66293a894d9bf66d097a7e431f Author: Slendi Date: Sat Apr 26 21:13:52 2025 +0300 Initial Signed-off-by: Slendi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..462d02f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +static + diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..faa26f5 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1745526057, + "narHash": "sha256-ITSpPDwvLBZBnPRS2bUcHY3gZSwis/uTe255QgMtTLA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f771eb401a46846c1aebd20552521b233dd7e18b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..997e0fc --- /dev/null +++ b/flake.nix @@ -0,0 +1,71 @@ +{ + description = "A simple pastebin service"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages.default = pkgs.buildGoModule { + pname = "slenpaste"; + version = "0.1.0"; + src = ./.; + goPackagePath = "github.com/slendidev/slenpaste"; + }; + + devShells.default = pkgs.mkShell { + buildInputs = [ pkgs.go pkgs.gopls ]; + }; + + nixosModules.slenpaste = { lib, pkgs, config, ... }: { + # module function + options.services.slenpaste.enable = lib.mkEnableOption "Enable slenpaste service"; + options.services.slenpaste.domain = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = "Domain to serve pastes from"; + }; + options.services.slenpaste.listen = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0:8080"; + description = "Listen address (host:port)"; + }; + options.services.slenpaste.expireDur = lib.mkOption { + type = lib.types.str; + default = "0"; + description = "Expiry duration (Go syntax, e.g. \"5m\", \"1h\" or \"0\" for none)"; + }; + options.services.slenpaste.expireOnView = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to expire on first view"; + }; + + config = lib.mkIf config.services.slenpaste.enable { + systemd.services.slenpaste = { + description = "slenpaste HTTP paste service"; + after = [ "network.target" ]; + wants = [ "network.target" ]; + serviceConfig = { + ExecStart = '' + ${pkgs.slenpaste}/bin/slenpaste \ + -domain ${config.services.slenpaste.domain} \ + -listen ${config.services.slenpaste.listen} \ + -expire ${config.services.slenpaste.expireDur} \ + -expire-on-view=${toString config.services.slenpaste.expireOnView} + ''; + Restart = "on-failure"; + }; + wantedBy = [ "multi-user.target" ]; + }; + }; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb0d1a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/slendidev/slenpaste + +go 1.24.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..7edd3ea --- /dev/null +++ b/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +var ( + domain string + listenAddr string + staticDir string + expireDur time.Duration + expireOnView bool +) + +type meta struct { + Expiry time.Time `json:"expiry"` + ExpireOnView bool `json:"expire_on_view"` +} + +func randomID(n int) string { + letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + fmt.Fprintf(w, `
Welcome to slenpaste!
+Upload via curl:
+  curl -F 'file=@yourfile.txt' http://%s/
+
+Or via wget:
+  wget --method=POST --body-file=yourfile.txt http://%s/
+
+
+ + +
+ Expiry: + + + + + +

+ + +
+`, domain, domain) +} + +func uploadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + return + } + + var reader io.Reader + if err := r.ParseMultipartForm(10 << 20); err == nil { + if file, _, err := r.FormFile("file"); err == nil { + defer file.Close() + reader = file + } + } + if reader == nil { + reader = r.Body + defer r.Body.Close() + } + + expVal := r.FormValue("expiry") + var dur time.Duration + var onView bool + switch expVal { + case "view": + onView = true + case "0": + // no expiry + default: + dur, _ = time.ParseDuration(expVal) + } + + id := randomID(6) + if err := os.MkdirAll(staticDir, 0755); err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + path := filepath.Join(staticDir, id) + out, err := os.Create(path) + if err != nil { + http.Error(w, "Save error", http.StatusInternalServerError) + return + } + defer out.Close() + if _, err := io.Copy(out, reader); err != nil { + http.Error(w, "Write error", http.StatusInternalServerError) + return + } + + if dur > 0 || onView { + m := meta{ExpireOnView: onView} + if dur > 0 { + m.Expiry = time.Now().Add(dur) + } + metaBytes, _ := json.Marshal(m) + _ = os.WriteFile(path+".json", metaBytes, 0644) + } + + fmt.Fprintf(w, "http://%s/%s\n", domain, id) +} + +func viewHandler(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/") + if id == "" { + indexHandler(w, r) + return + } + path := filepath.Join(staticDir, id) + metaPath := path + ".json" + + // load and enforce metadata + if data, err := os.ReadFile(metaPath); err == nil { + var m meta + if err := json.Unmarshal(data, &m); err == nil { + if !m.Expiry.IsZero() && time.Now().After(m.Expiry) { + os.Remove(path) + os.Remove(metaPath) + http.NotFound(w, r) + return + } + if m.ExpireOnView { + defer os.Remove(path) + defer os.Remove(metaPath) + } + } + } + + f, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + w.Header().Set("Content-Type", "text/plain") + io.Copy(w, f) +} + +func main() { + flag.StringVar(&domain, "domain", "localhost", "domain name for URLs") + flag.StringVar(&listenAddr, "listen", "0.0.0.0:8080", "listen address") + flag.StringVar(&staticDir, "static", "static", "directory to save pastes") + flag.DurationVar(&expireDur, "expire", 0, "time after which paste expires (e.g. 5m, 1h)") + flag.BoolVar(&expireOnView, "expire-on-view", false, "delete paste after it's viewed once") + flag.Parse() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + uploadHandler(w, r) + } else { + viewHandler(w, r) + } + }) + + fmt.Printf("slenpaste running at http://%s, storing in %s\n", listenAddr, staticDir) + http.ListenAndServe(listenAddr, nil) +}