summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEwy~ <ewy0@protonmail.com>2026-03-31 17:18:35 +0200
committerEwy~ <ewy0@protonmail.com>2026-03-31 17:18:35 +0200
commitcf085e93b7abc53b8ecc78c07210384e0ff8d516 (patch)
tree270bc83c7e4d1c468bd7fcc61d2d65d6f447eda6
initial commit
-rw-r--r--go.mod9
-rw-r--r--main.go40
-rw-r--r--pages/create.go85
-rw-r--r--pages/create.gohtml22
-rw-r--r--pages/get.go53
-rw-r--r--pages/landing.go19
-rw-r--r--pages/landing.gohtml40
-rw-r--r--pages/receive.go5
-rw-r--r--storage/error.go5
-rw-r--r--storage/link.go63
-rw-r--r--storage/saveable.go7
-rw-r--r--storage/sqlite/sqlite.go132
-rw-r--r--storage/storage.go15
13 files changed, 495 insertions, 0 deletions
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e229c11
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d30f292
--- /dev/null
+++ b/main.go
@@ -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]