diff options
| author | Ewy~ <ewy0@protonmail.com> | 2026-03-31 17:18:35 +0200 |
|---|---|---|
| committer | Ewy~ <ewy0@protonmail.com> | 2026-03-31 17:18:35 +0200 |
| commit | cf085e93b7abc53b8ecc78c07210384e0ff8d516 (patch) | |
| tree | 270bc83c7e4d1c468bd7fcc61d2d65d6f447eda6 | |
initial commit
| -rw-r--r-- | go.mod | 9 | ||||
| -rw-r--r-- | main.go | 40 | ||||
| -rw-r--r-- | pages/create.go | 85 | ||||
| -rw-r--r-- | pages/create.gohtml | 22 | ||||
| -rw-r--r-- | pages/get.go | 53 | ||||
| -rw-r--r-- | pages/landing.go | 19 | ||||
| -rw-r--r-- | pages/landing.gohtml | 40 | ||||
| -rw-r--r-- | pages/receive.go | 5 | ||||
| -rw-r--r-- | storage/error.go | 5 | ||||
| -rw-r--r-- | storage/link.go | 63 | ||||
| -rw-r--r-- | storage/saveable.go | 7 | ||||
| -rw-r--r-- | storage/sqlite/sqlite.go | 132 | ||||
| -rw-r--r-- | storage/storage.go | 15 |
13 files changed, 495 insertions, 0 deletions
@@ -0,0 +1,9 @@ +module delayed.link + +go 1.26.0 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-sqlite3 v1.14.38 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) @@ -0,0 +1,40 @@ +package main + +import ( + "delayed.link/pages" + _ "delayed.link/pages" + "delayed.link/storage" + "delayed.link/storage/sqlite" + "github.com/spf13/pflag" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" +) + +var ( + port = pflag.StringP("port", "p", "8080", "port to listen on") + host = pflag.StringP("host", "h", "0.0.0.0", "host to listen on") +) + +func main() { + pflag.Parse() + + http.HandleFunc("GET /{$}", pages.Land) + http.HandleFunc("POST /", pages.Create) + http.HandleFunc("GET /l/{id}", pages.Get) + + storage.Current = sqlite.New() + defer storage.Current.Close() + err := http.ListenAndServe(net.JoinHostPort(*host, *port), http.DefaultServeMux) + log.Printf("Started listening on %v:%v\n", *host, *port) + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch + if err != nil { + log.Panic(err) + } + +} diff --git a/pages/create.go b/pages/create.go new file mode 100644 index 0000000..03f4159 --- /dev/null +++ b/pages/create.go @@ -0,0 +1,85 @@ +package pages + +import ( + "delayed.link/storage" + _ "embed" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func CreateLink(w http.ResponseWriter, r *http.Request) *storage.Link { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil + } + form := r.PostForm + + minutesInFutureStr := form.Get("minutes_in_future") + if strings.TrimSpace(minutesInFutureStr) == "" { + http.Error(w, "minutes_in_future is required", http.StatusBadRequest) + return nil + } + minutesInFuture, err := strconv.Atoi(minutesInFutureStr) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil + } + if minutesInFuture < 0 { + http.Error(w, "minutes_in_future has to be > 0", http.StatusBadRequest) + } + numberOfOpensStr := form.Get("number_of_opens") + if strings.TrimSpace(minutesInFutureStr) == "" { + http.Error(w, "number_of_opens is required", http.StatusBadRequest) + return nil + } + numberOfOpens, err := strconv.Atoi(numberOfOpensStr) + if numberOfOpens < 0 || numberOfOpens > 100 { + http.Error(w, "number_of_opens needs to be between 1 and 100", http.StatusBadRequest) + return nil + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil + } + payload := form.Get("payload") + //TODO: Verify payload + if _, err := url.Parse(payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil + } + + l := storage.Link{ + Target: payload, + OpensFrom: time.Now().Add(time.Duration(minutesInFuture) * time.Minute), + OpensLeft: numberOfOpens, + } + + id, err := l.Save(storage.Current) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil + } + (&l).Id = id + + return &l +} + +//go:embed create.gohtml +var recContent string +var recTmpl = template.Must(template.New("delayed.link").Parse(recContent)) + +func Create(w http.ResponseWriter, r *http.Request) { + l := CreateLink(w, r) + if l == nil { + return + } + err := recTmpl.Execute(w, l) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/pages/create.gohtml b/pages/create.gohtml new file mode 100644 index 0000000..ac9890c --- /dev/null +++ b/pages/create.gohtml @@ -0,0 +1,22 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="ie=edge"> + <link rel="icon" href="data:," /> + <title>delayed.link: created</title> +</head> +<body> +<p> + <a href="/l/{{.Id}}">This link will unlock at {{.OpensFrom}}</a> +</p> +<p> + It will be able to be opened {{ .OpensLeft }} times. +</p> +<p> + This page is completely ephemeral. Once you close it, you cannot access the link again. +</p> +</body> +</html>
\ No newline at end of file diff --git a/pages/get.go b/pages/get.go new file mode 100644 index 0000000..7f7df2f --- /dev/null +++ b/pages/get.go @@ -0,0 +1,53 @@ +package pages + +import ( + "delayed.link/storage" + _ "embed" + "errors" + "fmt" + "github.com/google/uuid" + "net/http" + "time" +) + +func Get(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + uid, err := uuid.Parse(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + l, err := storage.Current.Load(uid) + if err != nil && errors.Is(err, storage.NotFoundError) { + // if not found error, return 404 + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } else if err != nil { + // else return internal error + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + valid := l.Valid() + if errors.Is(valid, storage.NotFoundError) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + if errors.Is(valid, storage.NoOpensError) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + if errors.Is(valid, storage.NotYetOpenError) { + http.Error(w, http.StatusText(http.StatusLocked), http.StatusLocked) + msg := fmt.Sprintf("\n\nThis link will be usable in %v minutes.", int(l.OpensFrom.Sub(time.Now()).Minutes())) + w.Write([]byte(msg)) + return + } + + err = l.Use() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + http.Redirect(w, r, l.Target, http.StatusFound) +} diff --git a/pages/landing.go b/pages/landing.go new file mode 100644 index 0000000..d8c4f54 --- /dev/null +++ b/pages/landing.go @@ -0,0 +1,19 @@ +package pages + +import ( + _ "embed" + "html/template" + "net/http" +) + +//go:embed landing.gohtml +var landContent string +var landTmpl = template.Must(template.New("delayed.link").Parse(landContent)) + +func Land(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + err := landTmpl.Execute(w, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/pages/landing.gohtml b/pages/landing.gohtml new file mode 100644 index 0000000..ceb55d9 --- /dev/null +++ b/pages/landing.gohtml @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <link rel="icon" href="data:," /> + <title>delayed.link</title> + <style> + main { + min-height: 105vh; + } + </style> +</head> +<body> +<main> + <form method="POST" action="/"> + <label> + Lock time in minutes + <input type="number" name="minutes_in_future"/> + </label> + <label> + Maximum times the link can be opened + <input type="number" name="number_of_opens" min="1" max="100"/> + </label> + <label> + URL to save for later + <input type="text" name="payload"/> + </label> + <input type="submit" value="Create"/> + </form> +</main> +<footer> + THERE IS NO WARRANTY FOR THE SOFTWARE, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN + WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE SOFTWARE “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER + EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH THE CUSTOMER. SHOULD + THE SOFTWARE PROVE DEFECTIVE, THE CUSTOMER ASSUMES THE COST OF ALL NECESSARY SERVICING, REPAIR, OR CORRECTION EXCEPT + TO THE EXTENT SET OUT UNDER THE HARDWARE WARRANTY IN THESE TERMS. +</footer> +</body> +</html>
\ No newline at end of file diff --git a/pages/receive.go b/pages/receive.go new file mode 100644 index 0000000..023a20b --- /dev/null +++ b/pages/receive.go @@ -0,0 +1,5 @@ +package pages + +import ( + _ "embed" +) diff --git a/storage/error.go b/storage/error.go new file mode 100644 index 0000000..956a564 --- /dev/null +++ b/storage/error.go @@ -0,0 +1,5 @@ +package storage + +import "errors" + +var NotFoundError = errors.New("link not found")
\ No newline at end of file diff --git a/storage/link.go b/storage/link.go new file mode 100644 index 0000000..f334f42 --- /dev/null +++ b/storage/link.go @@ -0,0 +1,63 @@ +package storage + +import ( + "encoding/json" + "errors" + "github.com/google/uuid" + "time" +) + +type Link struct { + Id uuid.UUID + Target string + OpensFrom time.Time + OpensLeft int +} + +var Null = uuid.UUID{} + +func (l Link) Save(storage Storage[Link, uuid.UUID]) (uuid.UUID, error) { + return storage.Save(l) +} + +func (l Link) Serialize() ([]byte, error) { + return json.Marshal(l) +} + +func (l Link) Use() error { + if e := l.Valid(); e != nil { + return e + } + + remaining := l.OpensLeft - 1 + if remaining < 1 { + _ = Current.Delete(l) + return nil + } + n := Link{ + Id: l.Id, + Target: l.Target, + OpensFrom: l.OpensFrom, + OpensLeft: remaining, + } + return n.Update(Current) +} + +func (l Link) Update(storage Storage[Link, uuid.UUID]) error { + return storage.Update(l) +} + +var NoOpensError = errors.New("no opens left") +var NotYetOpenError = errors.New("not yet open") + +func (l Link) Valid() error { + if l.OpensLeft < 1 { + return NoOpensError + } + + if time.Now().Before(l.OpensFrom) { + return NotYetOpenError + } + + return nil +} diff --git a/storage/saveable.go b/storage/saveable.go new file mode 100644 index 0000000..46a53f2 --- /dev/null +++ b/storage/saveable.go @@ -0,0 +1,7 @@ +package storage + +type Saveable[K comparable] interface { + Save(storage Storage[Saveable[K], K]) (K, error) + Update(storage Storage[Saveable[K], K]) (K, error) + Serialize() ([]byte, error) +} diff --git a/storage/sqlite/sqlite.go b/storage/sqlite/sqlite.go new file mode 100644 index 0000000..9372828 --- /dev/null +++ b/storage/sqlite/sqlite.go @@ -0,0 +1,132 @@ +package sqlite + +import ( + "database/sql" + "delayed.link/storage" + "encoding/json" + "errors" + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + "github.com/spf13/pflag" + "log" +) + +var ( + dbFile = pflag.StringP("dbfile", "d", "delayedlink.db", "") +) + +type SqliteStorage struct { + *sql.DB +} + +func (s *SqliteStorage) Close() error { + return s.DB.Close() +} + +func (s *SqliteStorage) Save(item storage.Link) (uuid.UUID, error) { + stmt, err := s.Prepare("INSERT INTO links VALUES (?, ?)") + if err != nil { + return storage.Null, err + } + id := uuid.New() + item.Id = id + obj, err := item.Serialize() + if err != nil { + return storage.Null, err + } + _, err = stmt.Exec(id, obj) + if err != nil { + return storage.Null, err + } + return id, nil +} + +const insertStatement = `SELECT (obj) FROM links WHERE id = ? LIMIT 1` + +func (s *SqliteStorage) Load(key uuid.UUID) (*storage.Link, error) { + stmt, err := s.DB.Prepare(insertStatement) + if err != nil { + return nil, err + } + rows, err := stmt.Query(key.String()) + if err != nil { + return nil, err + } + defer rows.Close() + var link *storage.Link + var data []byte + if !rows.Next() { + return nil, storage.NotFoundError + } + err = rows.Scan(&data) + if err != nil { + return nil, err + } + err = json.Unmarshal(data, &link) + if err != nil { + return nil, err + } + + return link, nil +} + +const creation_sql = `CREATE TABLE IF NOT EXISTS links ( + id UUID PRIMARY KEY, + obj JSONB NOT NULL +);` + +func New() *SqliteStorage { + db, err := sql.Open("sqlite3", *dbFile) + if err != nil { + log.Panic(err) + } + + _, err = db.Exec(creation_sql) + if err != nil { + panic(err) + } + + return &SqliteStorage{ + db, + } +} + +const deleteQuery = `DELETE FROM links WHERE id = ?` + +var NotFoundError = errors.New("entry not found") + +func (s *SqliteStorage) Delete(item storage.Link) error { + stmt, err := s.DB.Prepare(deleteQuery) + if err != nil { + return err + } + res, err := stmt.Exec(item.Id) + if err != nil { + return err + } + if aff, err := res.RowsAffected(); aff < 1 || err != nil { + if err == nil { + return NotFoundError + } + return err + } + return nil +} + +const updateQuery = `UPDATE links SET obj = ? WHERE id = ?` + +func (s *SqliteStorage) Update(item storage.Link) error { + stmt, err := s.DB.Prepare(updateQuery) + if err != nil { + return err + } + obj, err := item.Serialize() + if err != nil { + return err + } + _, err = stmt.Exec(item.Id, obj) + if err != nil { + return err + } + return nil +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..94d7056 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,15 @@ +package storage + +import ( + "github.com/google/uuid" +) + +type Storage[T any, K comparable] interface { + Save(item T) (K, error) + Update(item T) error + Load(key K) (*T, error) + Delete(item T) error + Close() error +} + +var Current Storage[Link, uuid.UUID] |
