initial bits for kog

This commit is contained in:
Aaron Bieber 2024-03-26 20:19:40 -06:00
commit 6d4bcb2ff2
No known key found for this signature in database
10 changed files with 461 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.direnv
*.bak
result
tags
db
kogs

15
LICENSE Normal file
View File

@ -0,0 +1,15 @@
/*
* Copyright (c) 2024 Aaron Bieber <aaron@bolddaemon.com>
*
* 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.
*/

3
README.org Normal file
View File

@ -0,0 +1,3 @@
# kogs
A [koreader](http://koreader.rocks/) sync server.

26
flake.lock Normal file
View File

@ -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
}

49
flake.nix Normal file
View File

@ -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 ];
};
});
};
}

7
go.mod Normal file
View File

@ -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

4
go.sum Normal file
View File

@ -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=

267
main.go Normal file
View File

@ -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)
}

83
module.nix Normal file
View File

@ -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"}
'';
};
};
};
}