Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • meskio/cicer
  • quique/cicer
2 results
Show changes
Showing with 2172 additions and 72 deletions
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
\ No newline at end of file
ext {
minSdkVersion = 22
compileSdkVersion = 33
targetSdkVersion = 33
androidxActivityVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.10.0'
androidxFragmentVersion = '1.5.6'
coreSplashScreenVersion = '1.0.0'
androidxWebkitVersion = '1.6.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
}
\ No newline at end of file
......@@ -3,41 +3,38 @@ package api
import (
"log"
"0xacab.org/meskio/cicer/api/db"
"github.com/gorilla/mux"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type api struct {
db *gorm.DB
db *db.DB
signKey []byte
mail *Mail
dues int
}
func initDB(dbPath string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, err
}
db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{},
&OrderPurchase{}, &Order{})
return db, err
}
func Init(dbPath string, signKey string, r *mux.Router) error {
db, err := initDB(dbPath)
func Init(dbPath string, signKey string, dues int, mail *Mail, r *mux.Router) error {
database, err := db.Init(dbPath)
if err != nil {
return err
}
a := api{db, []byte(signKey)}
a := api{database, []byte(signKey), mail, dues}
go a.refundOrders()
go a.cleanPaswordResets()
if dues > 0 {
go a.checkDues()
}
token, err := a.newToken(0, "admin", false)
log.Print(token)
r.HandleFunc("/signin", a.SignIn).Methods("POST")
r.HandleFunc("/token", a.GetToken).Methods("GET")
r.HandleFunc("/reset", a.SendPasswordReset).Methods("POST")
r.HandleFunc("/reset/{token}", a.ValidatePasswordReset).Methods("GET")
r.HandleFunc("/reset/{token}", a.PasswordReset).Methods("PUT")
r.HandleFunc("/member", a.authAdmin(a.ListMembers)).Methods("GET")
r.HandleFunc("/member", a.authAdmin(a.AddMember)).Methods("POST")
......@@ -46,7 +43,8 @@ func Init(dbPath string, signKey string, r *mux.Router) error {
r.HandleFunc("/member/{num:[0-9]+}", a.authAdmin(a.GetMember)).Methods("GET")
r.HandleFunc("/member/{num:[0-9]+}", a.authAdmin(a.UpdateMember)).Methods("PUT")
r.HandleFunc("/member/{num:[0-9]+}", a.authAdmin(a.DeleteMember)).Methods("DELETE")
r.HandleFunc("/member/{num:[0-9]+}/purchase", a.authAdmin(a.GetMemberTransactions)).Methods("GET")
r.HandleFunc("/member/{num:[0-9]+}/transactions", a.authAdmin(a.GetMemberTransactions)).Methods("GET")
r.HandleFunc("/member/{num:[0-9]+}/purchase", a.authAdminNum(a.AddMemberPurchase)).Methods("POST")
r.HandleFunc("/product", a.auth(a.ListProducts)).Methods("GET")
r.HandleFunc("/product", a.authAdmin(a.AddProduct)).Methods("POST")
......@@ -54,6 +52,13 @@ func Init(dbPath string, signKey string, r *mux.Router) error {
r.HandleFunc("/product/{code:[0-9]+}", a.authAdmin(a.UpdateProduct)).Methods("PUT")
r.HandleFunc("/product/{code:[0-9]+}", a.authAdmin(a.DeleteProduct)).Methods("DELETE")
r.HandleFunc("/supplier", a.auth(a.ListSuppliers)).Methods("GET")
r.HandleFunc("/supplier", a.authAdmin(a.AddSupplier)).Methods("POST")
r.HandleFunc("/inventary", a.authAdmin(a.ListInventary)).Methods("GET")
r.HandleFunc("/inventary/{id:[0-9]+}", a.authAdmin(a.GetInventary)).Methods("GET")
r.HandleFunc("/inventary", a.authAdminNum(a.AddInventary)).Methods("POST")
r.HandleFunc("/transaction", a.authAdmin(a.ListTransactions)).Methods("GET")
r.HandleFunc("/transaction/{id:[0-9]+}", a.authNumRole(a.GetTransaction)).Methods("GET")
r.HandleFunc("/transaction/mine", a.authNum(a.getTransactionsByMember)).Methods("GET")
......@@ -62,10 +67,19 @@ func Init(dbPath string, signKey string, r *mux.Router) error {
r.HandleFunc("/topup", a.authAdminNum(a.AddTopup)).Methods("POST")
r.HandleFunc("/order", a.auth(a.ListOrders)).Methods("GET")
r.HandleFunc("/order", a.authNum(a.AddOrder)).Methods("POST")
r.HandleFunc("/order", a.authOrderNum(a.AddOrder)).Methods("POST")
r.HandleFunc("/order/{id:[0-9]+}", a.authNum(a.GetOrder)).Methods("GET")
r.HandleFunc("/order/{id:[0-9]+}", a.authNumRole(a.UpdateOrder)).Methods("PUT")
r.HandleFunc("/order/{id:[0-9]+}", a.authNumRole(a.DeleteOrder)).Methods("DELETE")
r.HandleFunc("/order/{id:[0-9]+}/arrive", a.authNumRole(a.ArrivedOrder)).Methods("PUT")
r.HandleFunc("/order/{id:[0-9]+}/collected", a.authNum(a.CollectOrder)).Methods("PUT")
r.HandleFunc("/order/active", a.auth(a.ListActiveOrders)).Methods("GET")
r.HandleFunc("/order/purchase", a.authNum(a.AddOrderPurchase)).Methods("POST")
// TODO: r.HandleFunc("/order/purchase", a.authNum(a.UpdateOrderPurchase)).Methods("PUT")
r.HandleFunc("/order/picks", a.authOrderNum(a.ListOrderPicks)).Methods("GET")
r.HandleFunc("/order/unarrived", a.authNum(a.ListOrderUnarrived)).Methods("GET")
r.HandleFunc("/order/collectable", a.authNum(a.ListOrderCollectable)).Methods("GET")
r.HandleFunc("/order/{id:[0-9]+}/purchase", a.authNum(a.AddOrderPurchase)).Methods("POST")
r.HandleFunc("/dues", a.authAdmin(a.ListDues)).Methods("GET")
r.HandleFunc("/dues/mine", a.authNum(a.GetDuesByMember)).Methods("GET")
return nil
}
......@@ -36,10 +36,15 @@ type testAPI struct {
server *httptest.Server
testPath string
token string
tokenOrder string
tokenAdmin string
}
func newTestAPI(t *testing.T) *testAPI {
return newTestAPIDues(t, 0)
}
func newTestAPIDues(t *testing.T, dues int) *testAPI {
testPath, err := ioutil.TempDir(os.TempDir(), "cicer-test-")
if err != nil {
t.Fatal("Tempdir error:", err)
......@@ -47,7 +52,8 @@ func newTestAPI(t *testing.T) *testAPI {
dbPath := path.Join(testPath, "test.db")
r := mux.NewRouter()
err = Init(dbPath, signKey, r)
mail := NewMail("", "", "", "")
err = Init(dbPath, signKey, dues, mail, r)
if err != nil {
t.Fatal("Init error:", err)
}
......@@ -62,6 +68,15 @@ func newTestAPI(t *testing.T) *testAPI {
if err != nil {
t.Fatal("Can't generate token:", err)
}
tokenOrder := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"num": testMemberOrder.Num,
"role": "order",
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
tokenOrderString, err := tokenOrder.SignedString([]byte(signKey))
if err != nil {
t.Fatal("Can't generate token:", err)
}
tokenAdmin := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"num": testMemberAdmin.Num,
"role": "admin",
......@@ -72,13 +87,17 @@ func newTestAPI(t *testing.T) *testAPI {
t.Fatal("Can't generate token:", err)
}
return &testAPI{t, server.URL, &http.Client{}, server, testPath, tokenString, tokenAdminString}
return &testAPI{t, server.URL, &http.Client{}, server, testPath, tokenString, tokenOrderString, tokenAdminString}
}
func (ta *testAPI) do(method string, url string, body interface{}, respBody interface{}) *http.Response {
return ta.doToken(ta.token, method, url, body, respBody)
}
func (ta *testAPI) doOrder(method string, url string, body interface{}, respBody interface{}) *http.Response {
return ta.doToken(ta.tokenOrder, method, url, body, respBody)
}
func (ta *testAPI) doAdmin(method string, url string, body interface{}, respBody interface{}) *http.Response {
return ta.doToken(ta.tokenAdmin, method, url, body, respBody)
}
......
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"0xacab.org/meskio/cicer/api/db"
"github.com/dgrijalva/jwt-go"
"golang.org/x/crypto/argon2"
"github.com/gorilla/mux"
)
type creds struct {
......@@ -18,6 +18,15 @@ type creds struct {
NoExpire bool `json:"noExpire"`
}
type passwordResetPost struct {
Email string `json:"email"`
}
type passwordResetPut struct {
Password string `json:"password"`
Login string `json:"login"`
}
func (a *api) SignIn(w http.ResponseWriter, req *http.Request) {
var c creds
err := json.NewDecoder(req.Body).Decode(&c)
......@@ -26,16 +35,10 @@ func (a *api) SignIn(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
return
}
var member Member
err = a.db.Where("login = ?", c.Login).First(&member).Error
if err != nil {
log.Printf("Can't locate user %s: %v", c.Login, err)
w.WriteHeader(http.StatusBadRequest)
return
}
if !passwordValid(c.Password, member) {
log.Printf("Invalid pass for %s", c.Login)
member, err := a.db.Login(c.Login, c.Password)
if err != nil {
log.Printf("Invalid pass for %s: %v", c.Login, err)
w.WriteHeader(http.StatusBadRequest)
return
}
......@@ -66,28 +69,39 @@ func (a *api) GetToken(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
return
}
num, ok := claims["num"].(float64)
numFloat, ok := claims["num"].(float64)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
role, ok := claims["role"].(string)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
num := int(numFloat)
member, err := a.db.GetMember(num)
if err != nil {
if errors.Is(err, db.ErrorNotFound) {
w.WriteHeader(http.StatusUnauthorized)
} else {
log.Printf("Can't get the member %d: %v", num, err)
w.WriteHeader(http.StatusInternalServerError)
}
return
}
role := member.Role
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
token, err := a.newToken(int(num), role, true)
_, expires := claims["exp"]
newToken, err := a.newToken(int(num), role, expires)
if err != nil {
log.Printf("Can't create a token: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"token": newToken,
"role": role,
"disabled": member.Disabled,
})
if err != nil {
log.Printf("Can't encode token: %v", err)
......@@ -95,6 +109,91 @@ func (a *api) GetToken(w http.ResponseWriter, req *http.Request) {
}
}
func (a *api) SendPasswordReset(w http.ResponseWriter, req *http.Request) {
var reset passwordResetPost
err := json.NewDecoder(req.Body).Decode(&reset)
if err != nil {
log.Printf("Can't decode password reset request: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
member, token, err := a.db.NewPasswordReset(reset.Email)
if err != nil {
if errors.Is(err, db.ErrorNotFound) {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("Error creating password reset: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.mail.sendPasswordReset(member, "/reset/"+token)
if err != nil {
log.Printf("Error sending password reset: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte("Email sent"))
}
func (a *api) ValidatePasswordReset(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
token := vars["token"]
passwordReset, err := a.db.GetPasswordReset(token)
if err != nil {
log.Printf("Can't get password reset %s: %v", token, err)
if errors.Is(err, db.ErrorNotFound) {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(*passwordReset.Member)
if err != nil {
log.Printf("Can't encode member: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (a *api) PasswordReset(w http.ResponseWriter, req *http.Request) {
var reset passwordResetPut
err := json.NewDecoder(req.Body).Decode(&reset)
if err != nil {
log.Printf("Can't decode password reset put: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
vars := mux.Vars(req)
token := vars["token"]
err = a.db.ResetPassword(token, reset.Password, reset.Login)
if err != nil {
log.Printf("Can't reset password %s: %v", token, err)
if errors.Is(err, db.ErrorNotFound) {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusAccepted)
w.Write([]byte("Email sent"))
}
func (a *api) cleanPaswordResets() {
c := time.Tick(10 * time.Minute)
for range c {
a.db.CleanPasswordReset()
}
}
func (a *api) auth(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
token := req.Header.Get("x-authentication")
......@@ -162,6 +261,49 @@ func (a *api) authAdminNum(fn func(int, http.ResponseWriter, *http.Request)) fun
}
}
func roleOrder(role string) bool {
return role == "admin" || role == "order"
}
func (a *api) authOrder(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
token := req.Header.Get("x-authentication")
ok, claims := a.validateToken(token)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
role, ok := claims["role"].(string)
if !ok || !roleOrder(role) {
w.WriteHeader(http.StatusUnauthorized)
return
}
fn(w, req)
}
}
func (a *api) authOrderNum(fn func(int, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
token := req.Header.Get("x-authentication")
ok, claims := a.validateToken(token)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
role, ok := claims["role"].(string)
if !ok || !roleOrder(role) {
w.WriteHeader(http.StatusUnauthorized)
return
}
num, ok := claims["num"].(float64)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
fn(int(num), w, req)
}
}
func (a *api) authNumRole(fn func(int, string, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
token := req.Header.Get("x-authentication")
......@@ -221,30 +363,3 @@ func (a *api) validateToken(token string) (bool, jwt.MapClaims) {
}
return time.Unix(int64(exp), 0).After(time.Now()), claims
}
func newHashPass(password string) (hash []byte, salt []byte, err error) {
salt = make([]byte, 32)
_, err = rand.Read(salt)
if err != nil {
return
}
hash = hashPass(password, salt)
return
}
func passwordValid(password string, member Member) bool {
hash := hashPass(password, member.Salt)
return subtle.ConstantTimeCompare(hash, member.PassHash) == 1
}
func hashPass(password string, salt []byte) []byte {
const (
time = 1
memory = 64 * 1024
threads = 2
keyLen = 32
)
return argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen)
}
......@@ -3,6 +3,8 @@ package api
import (
"net/http"
"testing"
"0xacab.org/meskio/cicer/api/db"
)
func TestSignIn(t *testing.T) {
......@@ -17,18 +19,18 @@ func TestSignIn(t *testing.T) {
}
var respMember struct {
Token string `json:"token"`
Member Member `json:"member"`
Token string `json:"token"`
Member db.Member `json:"member"`
}
jsonAuth := creds{
Login: testMemberAdmin.Login,
Login: testMemberAdminLogin,
Password: testMemberAdmin.Password,
}
resp = tapi.do("POST", "/signin", jsonAuth, &respMember)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't sign in:", resp.Status)
}
if respMember.Member.Login != testMemberAdmin.Login {
if *respMember.Member.Login != *testMemberAdmin.Login {
t.Fatal("Unexpected member:", respMember)
}
tapi.token = respMember.Token
......
package db
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type DB struct {
db *gorm.DB
}
func Init(dbPath string) (*DB, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, err
}
db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{},
&OrderPurchase{}, &Order{}, &PasswordReset{}, &Supplier{},
&Inventary{}, &InventaryProduct{})
return &DB{db}, err
}
package db
import (
"errors"
"time"
"gorm.io/gorm"
)
func (d *DB) AddDuesIfNeeded(memberNum int, amount int) (transaction *Transaction, err error) {
err = d.db.Transaction(func(tx *gorm.DB) error {
var member Member
err = tx.Where("num = ?", memberNum).First(&member).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrorNotFound
}
return err
}
paid, err := areDuesPaid(tx, memberNum)
if err != nil {
return err
}
if paid {
if member.Disabled {
return updateMemberDisabled(tx, memberNum, false)
}
return nil
}
if member.Balance < amount {
if !member.Disabled {
return updateMemberDisabled(tx, memberNum, true)
}
return nil
}
transaction = &Transaction{
MemberNum: memberNum,
Date: time.Now(),
Type: "dues",
Total: -amount,
}
err = createTransaction(tx, transaction)
if err != nil {
return err
}
if member.Disabled {
return updateMemberDisabled(tx, memberNum, false)
}
return nil
})
return
}
func areDuesPaid(tx *gorm.DB, memberNum int) (bool, error) {
var lastDues Transaction
err := tx.Where("member = ? AND type = 'dues'", memberNum).
Order("date desc").
First(&lastDues).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return lastDues.Date.Month() == time.Now().Month(), nil
}
func updateMemberDisabled(tx *gorm.DB, memberNum int, disabled bool) error {
return tx.Model(&Member{}).
Where("num = ?", memberNum).
Update("disabled", disabled).Error
}
func ifDisabledError(tx *gorm.DB, memberNum int) error {
var member Member
err := tx.Where("num = ?", memberNum).First(&member).Error
if err != nil {
return err
}
if member.Disabled {
return ErrorMemberDisabled
}
return nil
}
package db
import (
"errors"
)
var (
ErrorBadPassword = errors.New("Bad password")
ErrorInvalidRequest = errors.New("Invalid request")
ErrorNotFound = errors.New("Record not found")
ErrorMemberDisabled = errors.New("Member is disabled")
)
package db
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Supplier struct {
gorm.Model
Name string `json:"name" gorm:"unique;index"`
}
type Inventary struct {
gorm.Model
MemberNum int `json:"-" gorm:"column:member"`
Member *Member `json:"member,omitempty" gorm:"foreignKey:MemberNum;references:Num"`
Date time.Time `json:"date"`
SupplierID *uint `json:"supplier_id"`
Supplier *Supplier `json:"supplier"`
Comment string `json:"comment"`
Products []InventaryProduct `json:"products"`
}
type InventaryProduct struct {
gorm.Model
InventaryID uint `json:"-"`
ProductCode int `json:"code" gorm:"column:product"`
Product *Product `json:"product" gorm:"foreignKey:ProductCode;references:Code"`
StockUpdate *int `json:"stock"`
Price *int `json:"price"`
}
func (d *DB) AddSupplier(supplier *Supplier) error {
return d.db.Create(supplier).Error
}
func (d *DB) ListSuppliers() (suppliers []Supplier, err error) {
err = d.db.Find(&suppliers).Error
return
}
func (d *DB) AddInventary(num int, inventary *Inventary) error {
inventary.Date = time.Now()
inventary.MemberNum = num
return d.db.Transaction(func(tx *gorm.DB) error {
for _, product := range inventary.Products {
query := tx.Model(&Product{}).
Where("code = ?", product.ProductCode)
if product.StockUpdate != nil {
query = query.Update("stock", gorm.Expr("stock + ?", *product.StockUpdate))
}
if product.Price != nil {
price, err := d.inventaryNewPrice(product)
if err != nil {
return err
}
query = query.Update("price", price)
}
err := query.Error
if err != nil {
return err
}
}
return tx.Create(inventary).Error
})
}
func (d *DB) inventaryNewPrice(product InventaryProduct) (int, error) {
if product.StockUpdate == nil || *product.StockUpdate == 0 {
return *product.Price, nil
}
existingProduct, err := d.GetProduct(product.ProductCode)
if err != nil {
return 0, err
}
price := ((existingProduct.Price * existingProduct.Stock) + (*product.Price * *product.StockUpdate)) / (existingProduct.Stock + *product.StockUpdate)
return price, nil
}
func (d *DB) GetInventary(id int) (inventary Inventary, err error) {
err = d.db.Preload("Products.Product").
Preload(clause.Associations).
First(&inventary, id).
Error
return
}
func (d *DB) ListInventary() (inventaries []Inventary, err error) {
err = d.db.Preload("Products.Product").
Preload(clause.Associations).
Order("date desc").
Find(&inventaries).
Error
return
}
package db
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"log"
"strings"
"time"
"golang.org/x/crypto/argon2"
"gorm.io/gorm"
)
const (
timeExpireResetToken = 7 * 24 * time.Hour
)
type Member struct {
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Num int `json:"num" gorm:"primaryKey"`
Login *string `json:"login" gorm:"unique;index"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Balance int `json:"balance"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
PassHash []byte `json:"-"`
Salt []byte `json:"-"`
}
type PasswordReset struct {
gorm.Model
Token string `gorm:"unique;index"`
MemberNum int `gorm:"column:member"`
Member *Member `gorm:"foreignKey:MemberNum;references:Num"`
}
type MemberReq struct {
Member
OldPassword string `json:"old_password"`
Password string `json:"password"`
}
func (d DB) AddMember(memberReq *MemberReq) (member Member, err error) {
member.Num = memberReq.Num
if memberReq.Login != nil {
member.Login = cleanLogin(*memberReq.Login)
}
member.Name = memberReq.Name
member.Email = strings.TrimSpace(memberReq.Email)
member.Phone = memberReq.Phone
member.Balance = memberReq.Balance
member.Role = memberReq.Role
member.PassHash, member.Salt, err = newHashPass(memberReq.Password)
if err != nil {
return
}
err = d.db.Create(&member).Error
return
}
func (d DB) ListMembers() (members []Member, err error) {
err = d.db.Find(&members).Error
return
}
func (d DB) GetMember(num int) (member Member, err error) {
err = d.db.Where("num = ?", num).First(&member).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
func (d DB) DeleteMember(num int) error {
return d.db.Where("num = ?", num).Delete(&Member{}).Error
}
func (d DB) UpdateMember(num int, member MemberReq, checkPass bool) (Member, error) {
var dbMember Member
err := d.db.Where("num = ?", num).First(&dbMember).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return dbMember, err
}
if checkPass && !passwordValid(member.OldPassword, dbMember) {
return dbMember, ErrorBadPassword
}
if member.Num != 0 {
dbMember.Num = member.Num
}
if member.Login != nil {
dbMember.Login = cleanLogin(*member.Login)
}
if member.Name != "" {
dbMember.Name = member.Name
}
if member.Email != "" {
dbMember.Email = strings.TrimSpace(member.Email)
}
if member.Phone != "" {
dbMember.Phone = member.Phone
}
if member.Role != "" {
dbMember.Role = member.Role
}
if member.Password != "" {
dbMember.PassHash, dbMember.Salt, err = newHashPass(member.Password)
if err != nil {
return dbMember, err
}
}
err = d.db.Save(&dbMember).Error
return dbMember, err
}
func (d DB) Login(login, password string) (member Member, err error) {
cleanedLogin := cleanLogin(login)
err = d.db.Where("email = ?", cleanedLogin).
First(&member).Error
if err != nil {
err = d.db.Where("login = ?", cleanedLogin).
First(&member).Error
if err != nil {
return
}
}
if !passwordValid(password, member) {
err = ErrorBadPassword
}
return
}
func (d DB) NewPasswordReset(email string) (member Member, token string, err error) {
err = d.db.Where("email = ?", email).First(&member).Error
if err != nil {
log.Printf("Can't locate user %s: %v", email, err)
err = ErrorNotFound
return
}
tokenBytes := make([]byte, 15)
_, err = rand.Read(tokenBytes)
if err != nil {
log.Printf("Can't generate a random token for password reset: %v", err)
return
}
token = base64.URLEncoding.EncodeToString(tokenBytes)
passwordReset := PasswordReset{
Token: token,
MemberNum: member.Num,
}
err = d.db.Create(&passwordReset).Error
return
}
func (d *DB) ResetPassword(token, password, login string) error {
passwordReset, err := d.GetPasswordReset(token)
if err != nil {
return err
}
var member Member
member.PassHash, member.Salt, err = newHashPass(password)
if err != nil {
return err
}
if login != "" {
member.Login = cleanLogin(login)
}
return d.db.Transaction(func(tx *gorm.DB) error {
err := tx.Model(&passwordReset.Member).
Updates(member).Error
if err != nil {
return err
}
return tx.Delete(passwordReset).Error
})
}
func (d *DB) GetPasswordReset(token string) (passwordReset PasswordReset, err error) {
err = d.db.Where("token = ?", token).
Preload("Member").
First(&passwordReset).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
func (d *DB) CleanPasswordReset() {
t := time.Now().Add(timeExpireResetToken)
res := d.db.Where("created_at < ?", true, t).
Delete(&PasswordReset{})
if res.Error != nil {
log.Println("Error deleting old reset tokens:", res.Error)
} else if res.RowsAffected != 0 {
log.Println("Deleted", res.RowsAffected, "password reset tokens")
}
}
func newHashPass(password string) (hash []byte, salt []byte, err error) {
salt = make([]byte, 16)
_, err = rand.Read(salt)
if err != nil {
return
}
hash = hashPass(password, salt)
return
}
func passwordValid(password string, member Member) bool {
hash := hashPass(password, member.Salt)
return subtle.ConstantTimeCompare(hash, member.PassHash) == 1
}
func hashPass(password string, salt []byte) []byte {
const (
time = 1
memory = 64 * 1024
threads = 2
keyLen = 32
)
return argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen)
}
func cleanLogin(login string) *string {
cleanedLogin := strings.ToLower(strings.TrimSpace(login))
return &cleanedLogin
}
package db
import (
"errors"
"log"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
updateOrderDuration = 10 * 24 * time.Hour
)
type Order struct {
gorm.Model
Name string `json:"name"`
Description string `json:"description"`
MemberNum int `json:"member_num" gorm:"column:member;index"`
Member *Member `json:"member,omitempty" gorm:"foreignKey:MemberNum;references:Num"`
Deadline time.Time `json:"deadline"`
Active bool `json:"active" gorm:"index"`
Arrived *time.Time `json:"arrived,omitempty" gorm:"index"`
Products []OrderProduct `json:"products"`
Transactions []Transaction `json:"transactions" gorm:"foreignKey:OrderID"`
TransactionID *uint `json:"-" gorm:"column:transaction"`
}
type OrderProduct struct {
gorm.Model
OrderID uint `json:"-"`
ProductCode int `json:"code"`
Product *Product `json:"product" gorm:"foreignKey:ProductCode;references:Code"`
Price int `json:"price"`
}
type OrderPurchase struct {
gorm.Model `json:"-"`
TransactionID uint `json:"-"`
OrderProductID uint `json:"order_product_id"`
OrderProduct *OrderProduct `json:"order_product" gorm:"constraint:OnDelete:CASCADE"`
Amount int `json:"amount"`
}
func (d *DB) ListOrders(active bool) (orders []Order, err error) {
query := d.db.Preload(clause.Associations).
Preload("Transactions.OrderPurchase")
if active {
query = query.Where("active = ?", true)
}
err = query.Order("deadline desc").
Find(&orders).Error
return
}
func (d *DB) ListOrderPicks(num int) (orders []Order, err error) {
err = d.db.Select("*, member = ? as member_selected", num).
Table("(?) as orders", d.db.Model(&Order{}).Order("deadline desc")).
Group("name").Order("member_selected desc, deadline desc").Limit(15).
Preload(clause.Associations).
Preload("Products.Product").
Find(&orders).Error
return
}
func (d *DB) ListOrderUnarrived(memberNum int) (orders []Order, err error) {
err = d.db.Preload(clause.Associations).
Preload("Transactions.OrderPurchase").
Where("member = ?", memberNum).
Where("deadline >= ?", time.Now().Add(-updateOrderDuration)).
Where("active is false").
Where("arrived is null").
Find(&orders).Error
return
}
func (d *DB) ListOrderCollectable(memberNum int) (transactions []Transaction, err error) {
err = d.db.Preload("OrderPurchase.OrderProduct.Product").
Preload("Order").
Joins("left join orders on transactions.order_id = orders.id").
Where("transactions.member = ?", memberNum).
Where("collected is null or collected = false").
Where("orders.Arrived is not null").
Find(&transactions).
Error
return
}
func (d *DB) GetOrder(memberNum int, id int) (order Order, transaction Transaction, err error) {
err = d.db.Preload(clause.Associations).
Preload("Products.Product").
Preload("Transactions.OrderPurchase").
Preload("Transactions.Member").
First(&order, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
if memberNum == 0 {
return
}
err = d.db.Where("member = ? AND type = 'order' AND order_id = ?", memberNum, id).
Preload("OrderPurchase.OrderProduct.Product").
Find(&transaction).Error
return
}
func (d *DB) AddOrder(order *Order) error {
return d.db.Create(&order).Error
}
func (d *DB) UpdateOrder(memberNum int, id int, order *Order) error {
dbOrder, _, err := d.GetOrder(0, id)
if err != nil {
return err
}
if memberNum != 0 && dbOrder.MemberNum != memberNum {
return ErrorInvalidRequest
}
if dbOrder.Deadline.Add(updateOrderDuration).Before(time.Now()) {
return ErrorInvalidRequest
}
for _, p := range order.Products {
err = d.orderProductExist(&p)
if err != nil {
return err
}
}
dbOrder.Name = order.Name
dbOrder.Description = order.Description
dbOrder.Deadline = order.Deadline
return d.db.Transaction(func(tx *gorm.DB) error {
totalSum := 0
for i, t := range dbOrder.Transactions {
var transaction Transaction
err = tx.Preload("OrderPurchase.OrderProduct").First(&transaction, t.ID).Error
if err != nil {
return err
}
total, _ := calculateOrderPurchaseTotal(transaction.OrderPurchase, order.Products)
err = updateOrderPurchase(tx, t.MemberNum, &dbOrder.Transactions[i], total, t.OrderPurchase)
if err != nil {
return err
}
totalSum += total
}
products, err := updateOrderProducts(tx, *order, dbOrder)
if err != nil {
return err
}
if dbOrder.TransactionID != nil {
err = updateOrderTransaction(tx, int(*dbOrder.TransactionID), totalSum, &dbOrder)
if err != nil {
return err
}
}
dbOrder.Products = products
dbOrder.Transactions = []Transaction{}
return tx.Save(&dbOrder).Error
})
}
func updateOrderProducts(tx *gorm.DB, order Order, dbOrder Order) (products []OrderProduct, err error) {
for _, product := range order.Products {
dbProduct := findOrderProduct(product.ProductCode, dbOrder.Products)
if dbProduct != nil {
dbProduct.Price = product.Price
products = append(products, *dbProduct)
err = tx.Save(dbProduct).Error
} else {
product.OrderID = uint(dbOrder.ID)
err = tx.Create(&product).Error
products = append(products, product)
}
if err != nil {
return
}
}
for _, product := range dbOrder.Products {
if findOrderProduct(product.ProductCode, order.Products) == nil {
err = tx.Where("order_product_id = ?", product.ID).Delete(&OrderPurchase{}).Error
if err != nil {
return
}
err = tx.Delete(&product).Error
if err != nil {
return
}
}
}
return
}
func updateOrderTransaction(tx *gorm.DB, id int, total int, order *Order) error {
var transaction Transaction
err := tx.First(&transaction, id).Error
if err != nil {
return err
}
if order.Deadline.After(time.Now()) {
err := updateMemberBalance(tx, order.MemberNum, -transaction.Total)
if err != nil {
return err
}
err = tx.Delete(&transaction).Error
if err != nil {
return err
}
order.Active = true
order.Arrived = nil
order.TransactionID = nil
} else {
totalDiff := total - transaction.Total
err := updateMemberBalance(tx, order.MemberNum, totalDiff)
if err != nil {
return err
}
transaction.Total = total
err = tx.Save(&transaction).Error
if err != nil {
return err
}
}
return nil
}
func findOrderProduct(code int, products []OrderProduct) *OrderProduct {
for _, p := range products {
if p.ProductCode == code {
return &p
}
}
return nil
}
func (d *DB) orderProductExist(product *OrderProduct) error {
err := d.db.Where("code = ?", product.ProductCode).Find(&Product{}).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return err
}
func (d *DB) DeleteOrder(memberNum int, id int) error {
order, _, err := d.GetOrder(0, id)
if err != nil {
return err
}
if memberNum != 0 && order.MemberNum != memberNum {
return ErrorInvalidRequest
}
return d.db.Transaction(func(tx *gorm.DB) error {
if order.TransactionID != nil {
var transaction Transaction
err = tx.First(&transaction, order.TransactionID).Error
if err != nil {
return err
}
order.Transactions = append(order.Transactions, transaction)
}
for _, transaction := range order.Transactions {
err := updateMemberBalance(tx, transaction.MemberNum, -transaction.Total)
if err != nil {
return err
}
err = tx.Select("OrderPurchase").Delete(&transaction).Error
if err != nil {
return err
}
}
return tx.Delete(&order).Error
})
}
func (d *DB) AddOrderPurchase(memberNum int, orderID int, purchase []OrderPurchase) (transaction Transaction, err error) {
if err = ifDisabledError(d.db, memberNum); err != nil {
return
}
order, transaction, err := d.GetOrder(memberNum, orderID)
if err != nil {
return
}
if !order.Active {
err = ErrorInvalidRequest
log.Printf("Order is not active %d: %v", order.ID, purchase)
return
}
total, err := calculateOrderPurchaseTotal(purchase, order.Products)
if err != nil {
return
}
if transaction.ID != 0 {
transaction.Date = time.Now()
err = updateOrderPurchase(d.db, memberNum, &transaction, total, purchase)
return
}
transaction = Transaction{
MemberNum: memberNum,
Total: -total,
Type: "order",
Date: time.Now(),
OrderPurchase: purchase,
OrderID: &order.ID,
}
err = createTransaction(d.db, &transaction)
if err != nil {
return
}
return d.GetTransaction(int(transaction.ID))
}
func calculateOrderPurchaseTotal(purchase []OrderPurchase, products []OrderProduct) (total int, err error) {
total = 0
for _, p := range purchase {
found := false
for _, product := range products {
if (p.OrderProduct != nil && product.ProductCode == p.OrderProduct.ProductCode) ||
product.ID == p.OrderProductID {
total += product.Price * p.Amount
found = true
break
}
}
if !found {
log.Printf("Order purchase product %d not in order: %v", p.OrderProductID, products)
err = ErrorInvalidRequest
}
}
return total, err
}
func updateOrderPurchase(tx *gorm.DB, memberNum int, transaction *Transaction, total int, purchase []OrderPurchase) error {
totalDiff := -(transaction.Total + total)
transaction.Total = -total
var updatePurchases []OrderPurchase
var newPurchases []OrderPurchase
for _, new_purchase := range purchase {
found := false
for i, p := range transaction.OrderPurchase {
if new_purchase.OrderProductID == p.OrderProductID {
transaction.OrderPurchase[i].Amount = new_purchase.Amount
updatePurchases = append(updatePurchases, transaction.OrderPurchase[i])
found = true
break
}
}
if !found {
newPurchases = append(newPurchases, OrderPurchase{
Amount: new_purchase.Amount,
TransactionID: transaction.ID,
OrderProductID: new_purchase.OrderProductID,
})
}
}
var delPurchases []OrderPurchase
for _, p := range transaction.OrderPurchase {
found := false
for _, p2 := range purchase {
if p.OrderProductID == p2.OrderProductID {
found = true
break
}
}
if !found {
delPurchases = append(delPurchases, p)
}
}
return tx.Transaction(func(tx *gorm.DB) error {
err := updateMemberBalance(tx, memberNum, totalDiff)
if err != nil {
return err
}
for _, p := range delPurchases {
err = tx.Delete(&p).Error
if err != nil {
return err
}
}
for _, p := range updatePurchases {
err = tx.Save(&p).Error
if err != nil {
return err
}
}
for _, p := range newPurchases {
err = tx.Create(&p).Error
if err != nil {
return err
}
}
return tx.Save(&transaction).Error
})
}
func (d *DB) DeactivateOrders() []Order {
var orders []Order
now := time.Now().UTC()
err := d.db.Where("active = ? AND deadline < ?", true, now).
Preload("Member").
Preload("Transactions.OrderPurchase.OrderProduct.Product").
Preload("Transactions.Member").
Find(&orders).Error
if err != nil {
log.Println("Error refunding orders:", err)
return []Order{}
}
var deactivatedOrders []Order
for _, order := range orders {
total := 0
for _, transaction := range order.Transactions {
for _, purchase := range transaction.OrderPurchase {
total += purchase.OrderProduct.Price * purchase.Amount
}
}
transaction := Transaction{
MemberNum: order.MemberNum,
Date: time.Now(),
Type: "refund",
Total: total,
}
err = d.db.Transaction(func(tx *gorm.DB) error {
err := createTransaction(tx, &transaction)
if err != nil {
return err
}
return tx.Model(&Order{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"active": false,
"transaction": transaction.ID}).
Error
})
if err != nil {
log.Printf("Can't create refund: %v\n%v", err, order)
continue
}
deactivatedOrders = append(deactivatedOrders, order)
log.Println("Refund order", order.Name, total)
}
return deactivatedOrders
}
func (d *DB) ArrivedOrder(memberNum int, id int) error {
dbOrder, _, err := d.GetOrder(0, id)
if err != nil {
return err
}
if memberNum != 0 && dbOrder.MemberNum != memberNum {
return ErrorInvalidRequest
}
if dbOrder.Deadline.Add(updateOrderDuration).Before(time.Now()) {
return ErrorInvalidRequest
}
if dbOrder.Active {
return ErrorInvalidRequest
}
return d.db.Model(&Order{}).Where("id = ?", id).Update("arrived", time.Now()).Error
}
func (d *DB) CollectOrder(memberNum int, id int) error {
dbOrder, t, err := d.GetOrder(memberNum, id)
if err != nil {
return err
}
if dbOrder.Active || dbOrder.Arrived == nil {
return ErrorInvalidRequest
}
return d.db.Model(&Transaction{}).Where("id = ?", t.ID).Update("collected", true).Error
}
package db
import (
"errors"
"time"
"gorm.io/gorm"
)
type Product struct {
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Code int `json:"code" gorm:"primaryKey"`
Name string `json:"name" gorm:"unique;index"`
Price int `json:"price"`
Stock int `json:"stock" gorm:"index"`
}
func (d *DB) AddProduct(product *Product) error {
return d.db.Create(&product).Error
}
func (d *DB) ListProducts() (products []Product, err error) {
err = d.db.Find(&products).Error
return
}
func (d *DB) GetProduct(code int) (product Product, err error) {
err = d.db.Where("code = ?", code).First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
func (d *DB) DeleteProduct(code int) error {
return d.db.Where("code = ?", code).
Delete(&Product{}).Error
}
func (d *DB) UpdateProduct(code int, product *Product) (dbProduct Product, err error) {
err = d.db.Where("code = ?", code).First(&dbProduct).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
if product.Code != 0 {
dbProduct.Code = product.Code
}
if product.Name != "" {
dbProduct.Name = product.Name
}
dbProduct.Price = product.Price
if product.Stock >= 0 {
dbProduct.Stock = product.Stock
}
err = d.db.Save(&dbProduct).Error
return
}
package db
import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
dateLayout = "2006-01-02"
)
type Transaction struct {
gorm.Model
MemberNum int `json:"-" gorm:"column:member;index"`
Member *Member `json:"member,omitempty" gorm:"foreignKey:MemberNum;references:Num"`
Date time.Time `json:"date"`
Total int `json:"total"`
Type string `json:"type"`
ProxyNum int `json:"-" gorm:"column:proxy"`
Proxy *Member `json:"proxy,omitempty" gorm:"foreignKey:ProxyNum;references:Num"`
Purchase []Purchase `json:"purchase,omitempty"`
Topup *Topup `json:"topup,omitempty"`
OrderPurchase []OrderPurchase `json:"order_purchase" gorm:"foreignKey:TransactionID;constraint:OnDelete:CASCADE"`
Order *Order `json:"order,omitempty" gorm:"constraint:OnDelete:CASCADE"`
OrderID *uint `json:"-"`
Refund *Order `json:"refund,omitempty" gorm:"foreignKey:TransactionID"`
Collected *bool `json:"collected,omitempty" gorm:"index"`
}
type Topup struct {
gorm.Model `json:"-"`
TransactionID uint `json:"-" gorm:"column:transaction"`
Comment string `json:"comment"`
}
type Purchase struct {
gorm.Model
TransactionID uint `json:"-" gorm:"column:transaction"`
ProductCode int `json:"code" gorm:"column:product"`
Product Product `json:"product" gorm:"foreignKey:ProductCode;references:Code"`
Price int `json:"price"`
Amount int `json:"amount"`
}
func (d *DB) ListTransactions(num int, query map[string][]string) (transactions []Transaction, err error) {
tx := d.transactionQuery()
if num != 0 {
tx = tx.Where("member = ?", num)
}
for k, v := range query {
switch k {
case "start-date":
var date time.Time
date, err = time.Parse(dateLayout, v[0])
if err != nil {
return
}
tx = tx.Where("date >= ?", date)
case "end-date":
var date time.Time
date, err = time.Parse(dateLayout, v[0])
date = date.Add(24 * time.Hour)
if err != nil {
return
}
tx = tx.Where("date <= ?", date)
case "member":
if num != 0 {
continue
}
tx = tx.Where("member in ?", v)
case "proxy":
tx = tx.Where("proxy in ?", v)
case "type":
tx = tx.Where("type in ?", v)
case "product":
var ids []interface{}
where := make([]string, len(v))
for i := range v {
var id int
id, err = strconv.Atoi(v[i])
if err != nil {
return
}
ids = append(ids, id, id)
where[i] = "purchases.product = ? OR order_products.product_code = ?"
}
tx = tx.Joins("left join purchases on purchases.`transaction` = transactions.id").
Joins("left join order_purchases on order_purchases.transaction_id = transactions.id").
Joins("left join order_products on order_products.id = order_purchases.order_product_id").
Where(strings.Join(where, " OR "), ids...)
default:
log.Printf("Unexpected transaction query: %s %v", k, v)
}
}
err = tx.Group("transactions.id").
Order("date desc").
Find(&transactions).Error
return
}
func (d *DB) GetTransaction(id int) (transaction Transaction, err error) {
err = d.transactionQuery().
First(&transaction, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = ErrorNotFound
}
return
}
func (d *DB) AddTopup(adminNum int, memberNum int, amount int, comment string) (transaction Transaction, err error) {
transaction = Transaction{
MemberNum: memberNum,
ProxyNum: adminNum,
Date: time.Now(),
Topup: &Topup{
Comment: comment,
},
Type: "topup",
Total: amount,
}
err = createTransaction(d.db, &transaction)
return
}
func (d *DB) AddPurchase(adminNum int, memberNum int, purchase []Purchase) (transaction Transaction, err error) {
total := 0
for i, p := range purchase {
var product Product
err = d.db.Where("code = ?", p.ProductCode).First(&product).Error
if err != nil {
log.Printf("Can't get product %d: %v", p.ProductCode, err)
err = ErrorNotFound
return
}
total += product.Price * p.Amount
purchase[i].Price = product.Price
}
if total == 0 {
log.Printf("Empty purchase (%d)", memberNum)
err = ErrorInvalidRequest
return
}
transaction = Transaction{
MemberNum: memberNum,
ProxyNum: adminNum,
Date: time.Now(),
Purchase: purchase,
Type: "purchase",
Total: -total,
}
err = d.db.Transaction(func(tx *gorm.DB) error {
if err := ifDisabledError(tx, memberNum); err != nil {
return err
}
err := createTransaction(tx, &transaction)
if err != nil {
return err
}
for _, p := range purchase {
err = tx.Model(&Product{}).
Where("code = ?", p.ProductCode).
Update("stock", gorm.Expr("stock - ?", p.Amount)).Error
if err != nil {
return fmt.Errorf("Can't update product stock %d-%d: %v", p.ProductCode, p.Amount, err)
}
}
return nil
})
return
}
func (d *DB) transactionQuery() *gorm.DB {
return d.db.Preload("Purchase.Product").
Preload("Order.Products").
Preload("OrderPurchase.OrderProduct.Product").
Preload(clause.Associations)
}
func createTransaction(db *gorm.DB, transaction *Transaction) error {
return db.Transaction(func(tx *gorm.DB) error {
err := updateMemberBalance(tx, transaction.MemberNum, transaction.Total)
if err != nil {
return err
}
return tx.Create(&transaction).Error
})
}
func updateMemberBalance(tx *gorm.DB, memberNum int, amount int) error {
var member Member
err := tx.Where("num = ?", memberNum).Find(&member).Error
if err != nil {
log.Printf("Can't find member for transaction %d: %v", memberNum, err)
return ErrorNotFound
}
if member.Balance < -amount {
log.Printf("Member %d don't have enough money (%d-%d)", member.Num, member.Balance, amount)
return ErrorInvalidRequest
}
err = tx.Model(&Member{}).
Where("num = ?", memberNum).
Update("balance", gorm.Expr("balance + ?", amount)).Error
if err != nil {
log.Printf("Can't update update member balance %d-%d: %v", member.Num, amount, err)
}
return err
}
package api
import (
"encoding/json"
"log"
"net/http"
"time"
)
func (a *api) ListDues(w http.ResponseWriter, req *http.Request) {
a.GetDuesByMember(0, w, req)
}
func (a *api) GetDuesByMember(num int, w http.ResponseWriter, req *http.Request) {
query := map[string][]string{"type": []string{"dues"}}
transactions, err := a.db.ListTransactions(num, query)
if err != nil {
log.Printf("Can't list dues: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(transactions)
if err != nil {
log.Printf("Can't encode dues: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
}
func (a *api) checkDues() {
c := time.Tick(time.Minute)
for range c {
members, err := a.db.ListMembers()
if err != nil {
log.Println("Error listing members to check dues:", err)
continue
}
for _, member := range members {
_, err := a.db.AddDuesIfNeeded(member.Num, a.dues)
if err != nil {
log.Println("Error charging dues to member", member.Num, ":", err)
}
}
}
}
package api
import (
"net/http"
"testing"
"0xacab.org/meskio/cicer/api/db"
)
func TestListDues(t *testing.T) {
const dues = 500
tapi := newTestAPIDues(t, dues)
defer tapi.close()
tapi.addTestMember()
topup := map[string]interface{}{
"member": testMember.Num,
"comment": "chargme dues",
"amount": 0,
}
resp := tapi.doAdmin("POST", "/topup", topup, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatal("Can't create topup:", resp.Status)
}
var transactions []db.Transaction
resp = tapi.do("GET", "/dues/mine", nil, &transactions)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't get transactions:", resp.Status)
}
if len(transactions) != 1 {
t.Fatal("Wrong number of transactions", len(transactions), transactions)
}
if transactions[0].Type != "dues" {
t.Error("Wrong type:", transactions[0].Type)
}
if transactions[0].Total != -dues {
t.Error("Wrong total:", transactions[0].Total)
}
}
func TestChargeDuesOnce(t *testing.T) {
const dues = 500
tapi := newTestAPIDues(t, dues)
defer tapi.close()
tapi.addTestMember()
topup := map[string]interface{}{
"member": testMember.Num,
"comment": "chargme dues",
"amount": 0,
}
resp := tapi.doAdmin("POST", "/topup", topup, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatal("Can't create topup:", resp.Status)
}
var transactions []db.Transaction
resp = tapi.do("GET", "/transaction/mine", nil, &transactions)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't get transactions:", resp.Status)
}
if len(transactions) != 2 {
t.Fatal("Wrong number of transactions", len(transactions), transactions)
}
if transactions[0].Type != "dues" {
t.Error("Wrong type:", transactions[0].Type)
}
if transactions[0].Total != -dues {
t.Error("Wrong total:", transactions[0].Total)
}
var member db.Member
resp = tapi.do("GET", "/member/me", nil, &member)
if resp.StatusCode != http.StatusOK {
t.Error("Can't find the member:", resp.Status)
}
if member.Balance != testMember.Balance-dues {
t.Error("Wrong product balance:", member.Balance)
}
// If dues are already payed they should not be charged a second time
resp = tapi.doAdmin("POST", "/topup", topup, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatal("Can't create topup:", resp.Status)
}
resp = tapi.do("GET", "/transaction/mine", nil, &transactions)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't get transactions:", resp.Status)
}
if len(transactions) != 3 {
t.Fatal("Wrong number of transactions", len(transactions), transactions)
}
resp = tapi.do("GET", "/member/me", nil, &member)
if resp.StatusCode != http.StatusOK {
t.Error("Can't find the member:", resp.Status)
}
if member.Balance != testMember.Balance-dues {
t.Error("Wrong member balance after dues:", member.Balance)
}
}
func TestMemberIsDisabled(t *testing.T) {
const dues = 500
tapi := newTestAPIDues(t, dues)
defer tapi.close()
tapi.addTestMember()
topup := map[string]interface{}{
"member": testMember.Num,
"comment": "empty my balance",
"amount": -testMember.Balance,
}
resp := tapi.doAdmin("POST", "/topup", topup, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatal("Can't create topup:", resp.Status)
}
// check that the dues hasn't being added as transaction
var transactions []db.Transaction
resp = tapi.do("GET", "/transaction/mine", nil, &transactions)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't get transactions:", resp.Status)
}
if len(transactions) != 1 {
t.Fatal("Wrong number of transactions", len(transactions), transactions)
}
// check that the member is disabled
var member db.Member
resp = tapi.do("GET", "/member/me", nil, &member)
if resp.StatusCode != http.StatusOK {
t.Error("Can't find the member:", resp.Status)
}
if member.Balance != 0 {
t.Error("Wrong member balance:", member.Balance)
}
if !member.Disabled {
t.Error("Member is not disabled")
}
// pay dues
topup["amount"] = dues
resp = tapi.doAdmin("POST", "/topup", topup, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatal("Can't create topup:", resp.Status)
}
resp = tapi.do("GET", "/transaction/mine", nil, &transactions)
if resp.StatusCode != http.StatusOK {
t.Fatal("Can't get transactions:", resp.Status)
}
if len(transactions) != 3 {
t.Fatal("Wrong number of transactions", len(transactions), transactions)
}
// check that the member is reenabled
resp = tapi.do("GET", "/member/me", nil, &member)
if resp.StatusCode != http.StatusOK {
t.Error("Can't find the member:", resp.Status)
}
if member.Balance != 0 {
t.Error("Wrong member balance:", member.Balance)
}
if member.Disabled {
t.Error("Member is still disabled")
}
}
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"0xacab.org/meskio/cicer/api/db"
"github.com/gorilla/mux"
)
func (a *api) AddSupplier(w http.ResponseWriter, req *http.Request) {
var supplier db.Supplier
err := json.NewDecoder(req.Body).Decode(&supplier)
if err != nil {
log.Printf("Can't decode supplier: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.db.AddSupplier(&supplier)
if err != nil {
log.Printf("Can't create supplier: %v\n%v", err, supplier)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(supplier)
if err != nil {
log.Printf("Can't encode added supplier: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (a *api) ListSuppliers(w http.ResponseWriter, req *http.Request) {
suppliers, err := a.db.ListSuppliers()
if err != nil {
log.Printf("Can't list suppliers: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(suppliers)
if err != nil {
log.Printf("Can't encode suppliers: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (a *api) AddInventary(num int, w http.ResponseWriter, req *http.Request) {
var inventary db.Inventary
err := json.NewDecoder(req.Body).Decode(&inventary)
if err != nil {
log.Printf("Can't decode inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = a.db.AddInventary(num, &inventary)
if err != nil {
log.Printf("Can't create inventary: %v\n%v", err, inventary)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(inventary)
if err != nil {
log.Printf("Can't encode added inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (a *api) GetInventary(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
id, _ := strconv.Atoi(vars["id"])
inventary, err := a.db.GetInventary(id)
if err != nil {
log.Printf("Can't get inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(inventary)
if err != nil {
log.Printf("Can't encode inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (a *api) ListInventary(w http.ResponseWriter, req *http.Request) {
inventary, err := a.db.ListInventary()
if err != nil {
log.Printf("Can't list inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(inventary)
if err != nil {
log.Printf("Can't encode inventary: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}