initial bits for kog
This commit is contained in:
commit
6d4bcb2ff2
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.direnv
|
||||
*.bak
|
||||
result
|
||||
tags
|
||||
db
|
||||
kogs
|
15
LICENSE
Normal file
15
LICENSE
Normal 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
3
README.org
Normal file
@ -0,0 +1,3 @@
|
||||
# kogs
|
||||
|
||||
A [koreader](http://koreader.rocks/) sync server.
|
26
flake.lock
generated
Normal file
26
flake.lock
generated
Normal 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
49
flake.nix
Normal 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
7
go.mod
Normal 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
4
go.sum
Normal 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
267
main.go
Normal 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
83
module.nix
Normal 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"}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user