From 6d4bcb2ff2706b4c486251b3cb1d6ae049db7790 Mon Sep 17 00:00:00 2001 From: Aaron Bieber Date: Tue, 26 Mar 2024 20:19:40 -0600 Subject: [PATCH] initial bits for kog --- .envrc | 1 + .gitignore | 6 ++ LICENSE | 15 +++ README.org | 3 + flake.lock | 26 ++++++ flake.nix | 49 ++++++++++ go.mod | 7 ++ go.sum | 4 + main.go | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++ module.nix | 83 +++++++++++++++++ 10 files changed, 461 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.org create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 module.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfa3982 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.direnv +*.bak +result +tags +db +kogs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5718c90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2024 Aaron Bieber + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ diff --git a/README.org b/README.org new file mode 100644 index 0000000..31b89e8 --- /dev/null +++ b/README.org @@ -0,0 +1,3 @@ +# kogs + +A [koreader](http://koreader.rocks/) sync server. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9f0286 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1711163522, + "narHash": "sha256-YN/Ciidm+A0fmJPWlHBGvVkcarYWSC+s3NTPk/P+q3c=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "44d0940ea560dee511026a53f0e2e2cde489b4d4", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..08a1ec3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,49 @@ +{ + description = "kogs: koreader sync server"; + + inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; + + outputs = + { self + , nixpkgs + , + }: + let + supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); + in + { + overlay = _: prev: { inherit (self.packages.${prev.system}) kogs; }; + nixosModule = import ./module.nix; + packages = forAllSystems (system: + let + pkgs = nixpkgsFor.${system}; + in + { + kogs = pkgs.buildGoModule { + pname = "kogs"; + version = "v0.1.0"; + src = ./.; + + vendorHash = "sha256-8AviacBPdpvuII/2symR1IgcT0Bf5OL6Do/6Go8TD1A="; + }; + }); + + defaultPackage = forAllSystems (system: self.packages.${system}.kogs); + devShells = forAllSystems (system: + let + pkgs = nixpkgsFor.${system}; + in + { + default = pkgs.mkShell { + shellHook = '' + PS1='\u@\h:\@; ' + nix run github:qbit/xin#flake-warn + echo "Go `${pkgs.go}/bin/go version`" + ''; + nativeBuildInputs = with pkgs; [ git go gopls go-tools ]; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a9ede6 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module suah.dev/kogs + +go 1.22.1 + +require github.com/peterbourgon/diskv/v3 v3.0.1 + +require github.com/google/btree v1.0.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7250e25 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= +github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7c01d1c --- /dev/null +++ b/main.go @@ -0,0 +1,267 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/peterbourgon/diskv/v3" +) + +type User struct { + Username string `json:"username"` + Password string + AuthKey string +} + +func (u *User) Key() string { + return fmt.Sprintf("user:%s:key", u.Username) +} + +func (u *User) Auth(authKey string) bool { + return u.AuthKey == authKey +} + +func (u *User) Created() []byte { + j, _ := json.Marshal(u) + return j +} + +func authUserFromHeader(d *diskv.Diskv, r *http.Request) (*User, error) { + un := r.Header.Get("x-auth-user") + uk := r.Header.Get("x-auth-key") + + u := &User{ + Username: un, + } + storedKey, err := d.Read(u.Key()) + if err != nil { + // No user + return nil, err + } + + u.AuthKey = string(storedKey) + if u.Auth(uk) { + return u, nil + } + + return nil, fmt.Errorf("Unauthorized") +} + +type Progress struct { + Device string `json:"device"` + Progress string `json:"progress"` + Document string `json:"document"` + Percentage float64 `json:"percentage"` + DeviceID string `json:"device_id"` + Timestamp int64 `json:"timestamp"` + User User +} + +func (p *Progress) DocKey() string { + return fmt.Sprintf("user:%s:document:%s", p.User.Username, p.Document) +} + +func (p *Progress) Save(d *diskv.Diskv) { + d.Write(p.DocKey()+"_percent", []byte(fmt.Sprintf("%f", p.Percentage))) + d.Write(p.DocKey()+"_progress", []byte(p.Progress)) + d.Write(p.DocKey()+"_device", []byte(p.Device)) + d.Write(p.DocKey()+"_device_id", []byte(p.DeviceID)) + d.Write(p.DocKey()+"_timestamp", []byte(fmt.Sprintf("%d", (time.Now().Unix())))) +} + +func (p *Progress) Get(d *diskv.Diskv) error { + if p.Document == "" { + return fmt.Errorf("invalid document") + } + pct, err := d.Read(p.DocKey() + "_percent") + if err != nil { + return err + } + p.Percentage, _ = strconv.ParseFloat(string(pct), 64) + + prog, err := d.Read(p.DocKey() + "_progress") + if err != nil { + return err + } + p.Progress = string(prog) + + dev, err := d.Read(p.DocKey() + "_device") + if err != nil { + return err + } + p.Device = string(dev) + + devID, err := d.Read(p.DocKey() + "_device_id") + if err != nil { + return err + } + p.DeviceID = string(devID) + + ts, err := d.Read(p.DocKey() + "_timestamp") + if err != nil { + return err + } + stamp, err := strconv.ParseInt(string(ts), 10, 64) + if err != nil { + return err + } + + p.Timestamp = stamp + + return nil +} + +func httpLog(r *http.Request) { + n := time.Now() + fmt.Printf("%s (%s) [%s] \"%s %s\" %03d\n", + r.RemoteAddr, + n.Format(time.RFC822Z), + r.Method, + r.URL.Path, + r.Proto, + r.ContentLength, + ) +} + +func main() { + reg := flag.Bool("reg", true, "enable user registration") + listen := flag.String("listen", ":8383", "interface and port to listen on") + flag.Parse() + d := diskv.New(diskv.Options{ + BasePath: "db", + Transform: func(s string) []string { return []string{} }, + CacheSizeMax: 1024 * 1024, + }) + + if !*reg { + log.Println("registration disabled") + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /users/create", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + if !*reg { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Registration disabled"}`)) + return + } + u := User{} + + dec := json.NewDecoder(r.Body) + err := dec.Decode(&u) + if err != nil { + log.Println(err) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + _, err = d.Read(u.Key()) + if err != nil { + d.Write(u.Key(), []byte(u.Password)) + } else { + log.Println("user exists") + http.Error(w, "Username is already registered", http.StatusPaymentRequired) + return + } + + w.Header().Add("Content-type", "application/json") + w.WriteHeader(201) + w.Write(u.Created()) + }) + mux.HandleFunc("GET /users/auth", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + _, err := authUserFromHeader(d, r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Unauthorized"}`)) + return + } + w.Header().Add("Content-type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"authorized": "OK"}`)) + }) + + mux.HandleFunc("PUT /syncs/progress", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + u, err := authUserFromHeader(d, r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Unauthorized"}`)) + return + } + prog := Progress{} + dec := json.NewDecoder(r.Body) + err = dec.Decode(&prog) + if err != nil { + log.Println(err) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + prog.User = *u + prog.Save(d) + + w.Header().Add("Content-type", "application/json") + w.WriteHeader(200) + w.Write([]byte(fmt.Sprintf(`{"document": "%s", "timestamp": "%d"}`, prog.Document, prog.Timestamp))) + }) + mux.HandleFunc("GET /syncs/progress/{document}", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + u, err := authUserFromHeader(d, r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Unauthorized"}`)) + return + } + prog := Progress{ + Document: r.PathValue("document"), + User: *u, + } + err = prog.Get(d) + if err != nil { + log.Println(err) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + b, err := json.Marshal(prog) + if err != nil { + log.Println(err) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + w.Header().Add("Content-type", "application/json") + w.WriteHeader(200) + w.Write(b) + }) + + mux.HandleFunc("GET /healthcheck", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + w.Header().Add("Content-type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"state": "OK"}`)) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + httpLog(r) + w.Header().Add("Content-type", "text/plain") + w.WriteHeader(200) + w.Write([]byte(`kogs: koreader sync server`)) + }) + + s := http.Server{ + Handler: mux, + } + + lis, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatal(err) + } + s.Serve(lis) +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..78af772 --- /dev/null +++ b/module.nix @@ -0,0 +1,83 @@ +{ lib, config, pkgs, ... }: +let cfg = config.services.kogs; +in { + options = with lib; { + services.kogs = { + enable = lib.mkEnableOption "Enable kogs"; + + listen = mkOption { + type = types.str; + default = ":8383"; + description = '' + Listen string + ''; + }; + + user = mkOption { + type = with types; oneOf [ str int ]; + default = "kogs"; + description = '' + The user the service will use. + ''; + }; + + group = mkOption { + type = with types; oneOf [ str int ]; + default = "kogs"; + description = '' + The group the service will use. + ''; + }; + + registration = mkOption { + type = types.bool; + default = true; + description = '' + Allow new users to register + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/kogs"; + description = "Path kogs will use to store the sqlite database"; + }; + + package = mkOption { + type = types.package; + default = pkgs.kogs; + defaultText = literalExpression "pkgs.kogs"; + description = "The package to use for kogs"; + }; + }; + }; + + config = lib.mkIf (cfg.enable) { + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + description = "kogs service user"; + isSystemUser = true; + home = "${cfg.dataDir}"; + createHome = true; + group = "${cfg.group}"; + }; + + systemd.services.kogs = { + enable = true; + description = "kogs server"; + wantedBy = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + environment = { HOME = "${cfg.dataDir}"; }; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + + ExecStart = '' + ${cfg.package}/bin/kogs -listen ${cfg.listen} ${lib.optionalString (cfg.registration) "-reg=false"} + ''; + }; + }; + }; +}