commit 6d4bcb2ff2706b4c486251b3cb1d6ae049db7790 Author: Aaron Bieber Date: Tue Mar 26 20:19:40 2024 -0600 initial bits for kog 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"} + ''; + }; + }; + }; +}