diff --git a/README.md b/README.md index 21726ab..2213b4d 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,12 @@ ### What is it? It's a URL shorten service that uses ATProto so that the users creating the short links will always own the links they are creating and gives them the option to host the service themselves so that they can be sure that the links will always be available. -Try it out [https://short.willdot.xyz] - ### Inspiration This project was inspired by a conversation with someone about the classic engineering interview question about creating a URL shortning service. One of the downfalls of this is that you're relying on the service to always be there so that your links don't die. I think Google announced that they were shutting down their service which caused uproar because it meant that existing links embeded in websites and other places would cease to work. Thankfully they announced that they would allow existing links to work, just not allow new ones to be created. But that scare of links no longer working, made me think about how the ATProto architecture is perfect for allowing users to own their links and be sure that they will always be available without depending on another service. ### How it works -When a user logs in with their ATProto account, they will be able to create a new short URL. This action creates a record in their PDS containing the URL to redirect to and in the future could contain other peices of metadata. The key bit here is that the record in their PDS is created with an RKey, which in this application is a TID ((timestamp identifier)[https://atproto.com/specs/tid]) which is unique and that key becomes the "short URL" ID. Combine that with the host that created the ID and you get a short URL. The users DID, key and URL are then stored in a local database to the service. +When a user logs in with their ATProto account, they will be able to create a new short URL. This action creates a record in their PDS containing the URL to redirect to and in the future could contain other peices of metadata. The key bit here is that the record in their PDS is created with an RKey, which in this application is a TID ([timestamp identifier](https://atproto.com/specs/tid)) which is unique and that key becomes the "short URL" ID. Combine that with the host that created the ID and you get a short URL. The users DID, key and URL are then stored in a local database to the service. As long as the service is running at that host, the short URL will be available to be redirected. When the short URL is hit, the service will take the key part of the URL, look it up in the database to find the real URL and then redirect to that URL. @@ -20,6 +18,8 @@ The service uses optimistic writes / deletes. It will create the database record ### Self hosting -Another neat part of this architecture is that if a user wants to self host this, they can and the links they create will only work for their service. For example. +Another neat part of this architecture is that it allows the user to self host the service. Traditional short URL services turn out to be a lot of hassle. Read this really interesting [thread on Bluesky](https://bsky.app/profile/gbl08ma.com/post/3m2pft7a3dc23) about it from the creator of [tny.im](tny.im). Turns out hosting one for others to use isn't a great move these days, so by having one that you can host yourself and only you can use, could be a really handy tool. You create the short URLs and pass them out to people to use. + +The way the service works, is that whe you start it up, you configure it to be for your DID (ATProto identifier) so that only you can log into the service to create short URLs. Then when the Jetstream consumer runs, it only listens to events for your DID which means you only get your short URL records in the local database. -If I create a short URL `https://my-short-service/a/abcd` on my service and then someone creates `https://a-different-short-service/a/1234` those links will only work with the domain that created them. I wouldn't be able to take the key `abcd` and use it with the `https://a-different-short-service` domain because that isn't the domain that created the link. This means that even though every service is consuming all of the short URL ATProto records, there will never be a case where someone could hijack an ID to redirect to somewhere else without the user that created and owning the link from doing so (because updating a record in a PDS requires the users auth). +Remember!!! All of the links you create are stored in your PDS which is public. So anyone that wants to see what links you've created and go to them, will be able to do that. diff --git a/auth_handlers.go b/auth_handlers.go index 9afd9b5..dd39100 100644 --- a/auth_handlers.go +++ b/auth_handlers.go @@ -25,6 +25,11 @@ func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) f return } + if did.String() != s.usersDID { + http.Error(w, "not authorized", http.StatusUnauthorized) + return + } + next(w, r) } } diff --git a/cmd/atshorter/main.go b/cmd/atshorter/main.go index 001c127..18c341b 100644 --- a/cmd/atshorter/main.go +++ b/cmd/atshorter/main.go @@ -51,6 +51,12 @@ func main() { return } + usersDID := os.Getenv("DID") + if usersDID == "" { + slog.Error("DID env not set") + return + } + dbFilename := path.Join(dbMountPath, "database.db") db, err := database.New(dbFilename) if err != nil { @@ -94,7 +100,7 @@ func main() { }, } - server, err := atshorter.NewServer(host, port, db, oauthClient, httpClient) + server, err := atshorter.NewServer(host, port, db, oauthClient, httpClient, usersDID) if err != nil { slog.Error("create new server", "error", err) return @@ -112,18 +118,18 @@ func main() { _ = server.Stop(context.Background()) }() - go consumeLoop(ctx, db) + go consumeLoop(ctx, db, usersDID) server.Run() } -func consumeLoop(ctx context.Context, db *database.DB) { +func consumeLoop(ctx context.Context, db *database.DB, did string) { jsServerAddr := os.Getenv("JS_SERVER_ADDR") if jsServerAddr == "" { jsServerAddr = defaultServerAddr } - consumer := atshorter.NewConsumer(jsServerAddr, slog.Default(), db) + consumer := atshorter.NewConsumer(jsServerAddr, slog.Default(), db, did) err := retry.Do(func() error { err := consumer.Consume(ctx) diff --git a/consumer.go b/consumer.go index 17295e0..02d479a 100644 --- a/consumer.go +++ b/consumer.go @@ -19,7 +19,7 @@ type consumer struct { logger *slog.Logger } -func NewConsumer(jsAddr string, logger *slog.Logger, store HandlerStore) *consumer { +func NewConsumer(jsAddr string, logger *slog.Logger, store HandlerStore, did string) *consumer { cfg := client.DefaultClientConfig() if jsAddr != "" { cfg.WebsocketURL = jsAddr @@ -27,7 +27,7 @@ func NewConsumer(jsAddr string, logger *slog.Logger, store HandlerStore) *consum cfg.WantedCollections = []string{ "com.atshorter.shorturl", } - cfg.WantedDids = []string{} // TODO: possibly when self hosting, limit this to just a select few? + cfg.WantedDids = []string{did} return &consumer{ cfg: cfg, diff --git a/example.env b/example.env index e8d765a..0900371 100644 --- a/example.env +++ b/example.env @@ -4,3 +4,4 @@ HOST="the host of the service such as https://my-url-shortner.com" DATABASE_PATH="./" JS_SERVER_ADDR="set to a different Jetstream instance" PORT="3002" +DID="Enter your account DID here" diff --git a/server.go b/server.go index 90ede53..2a875f7 100644 --- a/server.go +++ b/server.go @@ -29,6 +29,7 @@ type Store interface { type Server struct { host string + usersDID string httpserver *http.Server sessionStore *sessions.CookieStore templates []*template.Template @@ -44,7 +45,7 @@ type Server struct { //go:embed html var htmlFolder embed.FS -func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) { +func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client, usersDID string) (*Server, error) { sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html") @@ -64,6 +65,7 @@ func NewServer(host string, port string, store Store, oauthClient *oauth.ClientA srv := &Server{ host: host, + usersDID: usersDID, oauthClient: oauthClient, sessionStore: sessionStore, templates: templates,