feat: web login page (#1058)

This commit is contained in:
Levi authored and GitHub committed 2025-07-16 01:02:25 +08:00
1 parent 2927d1ef2b
commit 4f7b8a05ad
22 files changed
+771 -43

No files matched your search

+3 -3
View File
@@ -26,9 +26,9 @@ func parse() *args {
var cliArgs args
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd")
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling basic authentication")
cliArgs.Username = flag.String("u", "gopeed", "Web Authentication Username")
cliArgs.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
flag.Parse()
+3 -3
View File
@@ -23,9 +23,9 @@ func main() {
}
args := parse()
var webBasicAuth *model.WebBasicAuth
var webAuth *model.WebAuth
if isNotBlank(args.Username) && isNotBlank(args.Password) {
webBasicAuth = &model.WebBasicAuth{
webAuth = &model.WebAuth{
Username: *args.Username,
Password: *args.Password,
}
@@ -52,7 +52,7 @@ func main() {
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebBasicAuth: webBasicAuth,
WebAuth: webAuth,
}
cmd.Start(cfg)
}
+1 -2
View File
@@ -13,6 +13,7 @@ require (
github.com/go-git/go-git/v5 v5.8.1
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/imroc/req/v3 v3.52.2
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-ieproxy v0.0.12
github.com/pkg/errors v0.9.1
@@ -29,11 +30,9 @@ require (
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/imroc/req/v3 v3.52.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
+9 -1
View File
@@ -347,10 +347,11 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
@@ -399,6 +400,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -497,6 +500,7 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
@@ -675,6 +679,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -696,6 +702,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
+2
View File
@@ -309,6 +309,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
r.Header.Del("Authorization")
r.Header.Del("X-Target-Uri")
resp, err := http.DefaultClient.Do(r)
if err != nil {
writeError(w, err.Error())
+4 -11
View File
@@ -1,7 +1,6 @@
package model
import (
"encoding/base64"
"github.com/GopeedLab/gopeed/pkg/base"
"io/fs"
)
@@ -25,9 +24,9 @@ type StartConfig struct {
ProductionMode bool
WebEnable bool
WebFS fs.FS
WebBasicAuth *WebBasicAuth
WebEnable bool
WebFS fs.FS
WebAuth *WebAuth
}
func (cfg *StartConfig) Init() *StartConfig {
@@ -49,13 +48,7 @@ func (cfg *StartConfig) Init() *StartConfig {
return cfg
}
type WebBasicAuth struct {
type WebAuth struct {
Username string
Password string
}
// Authorization returns the value of the Authorization header to be used in HTTP requests.
func (cfg *WebBasicAuth) Authorization() string {
userId := cfg.Username + ":" + cfg.Password
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId))
}
+123 -9
View File
@@ -2,6 +2,10 @@ package rest
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/GopeedLab/gopeed/pkg/download"
@@ -10,12 +14,14 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
@@ -23,6 +29,7 @@ import (
var (
srv *http.Server
runningPort int
aesKey []byte
Downloader *download.Downloader
)
@@ -95,6 +102,13 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
util.SafeRemove(startCfg.Address)
}
if startCfg.WebEnable {
aesKey = make([]byte, 32)
if _, err := rand.Read(aesKey); err != nil {
return nil, nil, errors.Wrap(err, "generate aes key failed")
}
}
listener, err := net.Listen(startCfg.Network, startCfg.Address)
if err != nil {
return nil, nil, err
@@ -125,15 +139,40 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
enableApiToken := startCfg.ApiToken != ""
enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil
if startCfg.WebEnable {
if enableBasicAuth {
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var loginReq model.WebAuth
if ReadJson(r, w, &loginReq) {
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
// Generate a login token, Username:Password:Timestamp
timestamp := time.Now().Unix()
tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp)
token, err := aesEncrypt(aesKey, []byte(tokenData))
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewOkResult(token))
return
}
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
})
}
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
}
enableApiToken := startCfg.ApiToken != ""
enableBasicAuth := startCfg.WebEnable && startCfg.WebBasicAuth != nil
if enableApiToken || enableBasicAuth {
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
}
r.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enableApiToken {
@@ -145,19 +184,49 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
return
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
writeUnauthorized(w, r)
return
}
}
if startCfg.WebEnable && startCfg.WebBasicAuth != nil {
if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() {
if enableBasicAuth {
if r.URL.Path == "/api/web/login" {
h.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"")
token := r.Header.Get("Authorization")
if token == "" {
writeUnauthorized(w, r)
return
}
token = strings.TrimPrefix(token, "Bearer ")
tokenData, err := aesDecrypt(aesKey, token)
if err != nil {
writeUnauthorized(w, r)
return
}
parts := strings.SplitN(string(tokenData), ":", 3)
username := parts[0]
password := parts[1]
timestamp, _ := strconv.Atoi(parts[2])
if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {
writeUnauthorized(w, r)
return
}
// Check if the token is expired (7 days)
if time.Now().Unix()-int64(timestamp) > 7*24*3600 {
writeUnauthorized(w, r)
return
}
h.ServeHTTP(w, r)
return
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
writeUnauthorized(w, r)
})
})
}
@@ -177,9 +246,10 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
})
srv = &http.Server{Handler: handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowCredentials(),
)(r)}
return srv, listener, nil
}
@@ -315,3 +385,47 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(v)
}
func aesEncrypt(key, data []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
cipherText := gcm.Seal(nonce, nonce, data, nil)
return base64.StdEncoding.EncodeToString(cipherText), nil
}
func aesDecrypt(key []byte, encryptedData string) ([]byte, error) {
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(cipherText) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]
return gcm.Open(nil, nonce, cipherText, nil)
}
+52 -5
View File
@@ -637,7 +637,7 @@ func TestAuthorization(t *testing.T) {
cfg.Init()
cfg.ApiToken = "123456"
cfg.WebEnable = true
cfg.WebBasicAuth = &model.WebBasicAuth{
cfg.WebAuth = &model.WebAuth{
Username: "admin",
Password: "123456",
}
@@ -649,14 +649,61 @@ func TestAuthorization(t *testing.T) {
Stop()
}()
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{
Username: "xxx",
Password: "xxx",
})
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth)
authToken := fmt.Sprintf("Bearer %s", token)
authHeaders := map[string]string{
"Authorization": authToken,
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": "xxx",
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": "xxx",
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
buildToken := func(username, password string, ts int64) string {
token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts)))
return token
}
fakeToken := buildToken("fake", "fake", time.Now().Unix())
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", fakeToken),
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", expireToken),
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil)
if status != http.StatusOK {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
}
@@ -669,7 +716,7 @@ func TestAuthorization(t *testing.T) {
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": authToken,
"X-Api-Token": cfg.ApiToken,
}, nil)
if status != http.StatusOK {
@@ -677,7 +724,7 @@ func TestAuthorization(t *testing.T) {
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": authToken,
"X-Api-Token": "",
}, nil)
if status != http.StatusUnauthorized {
+37 -8
View File
@@ -3,13 +3,17 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart' as getx;
import '../app/routes/app_pages.dart';
import '../database/database.dart';
import '../util/util.dart';
import 'model/create_task.dart';
import 'model/create_task_batch.dart';
import 'model/downloader_config.dart';
import 'model/extension.dart';
import 'model/install_extension.dart';
import 'model/login.dart';
import 'model/request.dart';
import 'model/resolve_result.dart';
import 'model/result.dart';
@@ -43,12 +47,27 @@ class _Client {
dio.options.sendTimeout = const Duration(seconds: 5);
dio.options.connectTimeout = const Duration(seconds: 5);
dio.options.receiveTimeout = const Duration(seconds: 60);
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
}));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
if (Util.isWeb()) {
final token = Database.instance.getWebToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
},
onError: (error, handler) {
// Only web version has a login page
if (Util.isWeb() && error.response?.statusCode == 401) {
getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);
}
handler.next(error);
},
));
_instance!.dio = dio;
if (isUnixSocket) {
@@ -96,7 +115,8 @@ Future<T> _parse<T>(
} on DioException catch (e) {
if (e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionTimeout) {
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
throw TimeoutException("request timeout");
}
throw Exception(Result(code: 1000, msg: e.message));
@@ -216,6 +236,11 @@ Future<void> updateExtension(String identity) async {
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
}
Future<String> login(LoginReq loginReq) async {
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
(data) => data as String);
}
Future<Response<String>> proxyRequest<T>(String uri,
{data, Options? options}) async {
options ??= Options();
@@ -230,5 +255,9 @@ Future<Response<String>> proxyRequest<T>(String uri,
}
String join(String path) {
return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}";
final baseUrl = _client.dio.options.baseUrl;
final cleanBaseUrl = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
return "$cleanBaseUrl/${Util.cleanPath(path)}";
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login.g.dart';
@JsonSerializable()
class LoginReq {
String username;
String password;
LoginReq({
required this.username,
required this.password,
});
factory LoginReq.fromJson(Map<String, dynamic> json) =>
_$LoginReqFromJson(json);
Map<String, dynamic> toJson() => _$LoginReqToJson(this);
}
+17
View File
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginReq _$LoginReqFromJson(Map<String, dynamic> json) => LoginReq(
username: json['username'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$LoginReqToJson(LoginReq instance) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
};
@@ -16,6 +16,7 @@ import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/switch_extension.dart';
import '../../../../api/model/update_extension_settings.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
import '../../../views/icon_button_loading.dart';
@@ -135,6 +136,10 @@ class ExtensionView extends GetView<ExtensionController> {
'/fs/extensions/${extension.identity}/${extension.icon}'),
width: 48,
height: 48,
headers: {
'Authorization':
'Bearer ${Database.instance.getWebToken()}'
},
)
: Image.file(
extension.devMode
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/login_controller.dart';
class LoginBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LoginController>(
() => LoginController(),
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../api/api.dart' as api;
import '../../../../api/api.dart';
import '../../../../api/model/login.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../routes/app_pages.dart';
import '../../app/controllers/app_controller.dart';
class LoginController extends GetxController {
final formKey = GlobalKey<FormState>();
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final isLoading = false.obs;
final passwordVisible = false.obs;
@override
void onClose() {
usernameController.dispose();
passwordController.dispose();
super.onClose();
}
void togglePasswordVisibility() {
passwordVisible.value = !passwordVisible.value;
}
Future<void> login() async {
if (!formKey.currentState!.validate()) {
return;
}
isLoading.value = true;
try {
final loginReq = LoginReq(
username: usernameController.text.trim(),
password: passwordController.text,
);
final token = await api.login(loginReq);
// Login successful, save the token
Database.instance.saveWebToken(token);
// Reload config
final controller = Get.put(AppController());
await controller.loadDownloaderConfig();
// Navigate to home page
Get.rootDelegate.offAndToNamed(Routes.HOME);
} catch (e) {
if (e is TimeoutException) {
showMessage('error'.tr, 'login_failed_network'.tr);
} else {
showMessage('error'.tr, 'login_failed'.tr);
}
} finally {
isLoading.value = false;
}
}
}
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import '../../../views/responsive_builder.dart';
import '../controllers/login_controller.dart';
class LoginView extends GetView<LoginController> {
const LoginView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isNarrow = ResponsiveBuilder.isNarrow(context);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Get.theme.colorScheme.surface,
Get.theme.colorScheme.surface.withOpacity(0.8),
Get.theme.colorScheme.surface.withOpacity(0.9),
],
),
),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isNarrow ? 16.0 : 32.0,
vertical: isNarrow ? 24.0 : 32.0,
),
child: Card(
elevation: 12,
shadowColor: Get.theme.colorScheme.shadow.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
),
color: Get.theme.colorScheme.surface,
child: Container(
constraints: BoxConstraints(
maxWidth: isNarrow ? double.infinity : 420,
),
padding: EdgeInsets.all(isNarrow ? 24.0 : 40.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
border: Border.all(
color: Get.theme.colorScheme.outline.withOpacity(0.1),
width: 1,
),
),
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Form(
key: controller.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo/Title Section
Container(
alignment: Alignment.center,
margin: EdgeInsets.only(bottom: isNarrow ? 32 : 48),
child: Column(
children: [
Container(
padding: EdgeInsets.all(isNarrow ? 8 : 12),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(isNarrow ? 20 : 24),
boxShadow: [
BoxShadow(
color: Get.theme.colorScheme.primary
.withOpacity(0.2),
blurRadius: isNarrow ? 16 : 24,
offset: Offset(0, isNarrow ? 8 : 12),
spreadRadius: isNarrow ? 1 : 2,
),
],
),
child: ClipRRect(
borderRadius:
BorderRadius.circular(isNarrow ? 16 : 20),
child: SvgPicture.asset(
'assets/icon/icon.svg',
width: isNarrow ? 56 : 72,
height: isNarrow ? 56 : 72,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
Text(
'Gopeed',
style: Get.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Get.theme.colorScheme.onSurface,
letterSpacing: 2.0,
fontSize: isNarrow ? 28 : 36,
),
),
],
),
),
// Username Field
FocusTraversalOrder(
order: const NumericFocusOrder(1.0),
child: TextFormField(
controller: controller.usernameController,
autofillHints: const [
AutofillHints.username,
],
autofocus: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
decoration: InputDecoration(
labelText: 'username'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.person_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'username_required'.tr;
}
return null;
},
),
),
SizedBox(height: isNarrow ? 16 : 24),
// Password Field
FocusTraversalOrder(
order: const NumericFocusOrder(2.0),
child: Obx(() => TextFormField(
controller: controller.passwordController,
autofillHints: const [
AutofillHints.password,
],
obscureText: !controller.passwordVisible.value,
decoration: InputDecoration(
labelText: 'password'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.lock_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
suffixIcon: IconButton(
icon: Icon(
controller.passwordVisible.value
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
color: Get.theme.colorScheme.onSurface
.withOpacity(0.6),
),
onPressed:
controller.togglePasswordVisibility,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'password_required'.tr;
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
)),
),
SizedBox(height: isNarrow ? 24 : 32),
// Login Button
Obx(() => ElevatedButton(
onPressed: controller.isLoading.value
? null
: controller.login,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(
vertical: isNarrow ? 16 : 18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: controller.isLoading.value
? Get.theme.disabledColor
: Get.theme.colorScheme.primary,
foregroundColor:
Get.theme.colorScheme.onPrimary,
elevation: controller.isLoading.value ? 0 : 4,
shadowColor: Get.theme.colorScheme.primary
.withOpacity(0.4),
),
child: controller.isLoading.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(Get
.theme.colorScheme.onPrimary),
),
)
: Text(
'login'.tr,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
)),
],
),
),
),
),
),
),
),
),
);
}
}
+8
View File
@@ -6,6 +6,8 @@ import '../modules/extension/bindings/extension_binding.dart';
import '../modules/extension/views/extension_view.dart';
import '../modules/home/bindings/home_binding.dart';
import '../modules/home/views/home_view.dart';
import '../modules/login/bindings/login_binding.dart';
import '../modules/login/views/login_view.dart';
import '../modules/redirect/bindings/redirect_binding.dart';
import '../modules/redirect/views/redirect_view.dart';
import '../modules/root/bindings/root_binding.dart';
@@ -63,6 +65,12 @@ class AppPages {
binding: SettingBinding(),
),
]),
GetPage(
name: _Paths.LOGIN,
page: () => const LoginView(),
binding: LoginBinding(),
transition: Transition.fadeIn,
),
GetPage(
name: _Paths.CREATE,
transition: Transition.downToUp,
@@ -7,6 +7,7 @@ abstract class Routes {
static const ROOT = _Paths.ROOT;
static const HOME = _Paths.HOME;
static const CREATE = _Paths.CREATE;
static const LOGIN = _Paths.LOGIN;
static const TASK = _Paths.HOME + _Paths.TASK;
static const TASK_FILES = TASK + _Paths.TASK_FILES;
static const EXTENSION = _Paths.HOME + _Paths.EXTENSION;
@@ -20,6 +21,7 @@ abstract class _Paths {
static const ROOT = '/';
static const HOME = '/home';
static const CREATE = '/create';
static const LOGIN = '/login';
static const TASK = '/task';
static const TASK_FILES = '/files';
static const EXTENSION = '/extension';
+9 -1
View File
@@ -6,10 +6,10 @@ import 'package:hive/hive.dart';
import 'entity.dart';
const String _startConfig = 'startConfig';
const String _lastRunningConfig = 'lastRunningConfig';
const String _windowState = 'windowState';
const String _bookmark = 'bookmark';
const String _createHistory = 'createHistory';
const String _webToken = 'webToken';
class Database {
static final Database _instance = Database._internal();
@@ -82,6 +82,14 @@ class Database {
});
}
void saveWebToken(String token) {
save<String>(_webToken, token);
}
String? getWebToken() {
return get<String>(_webToken, (json) => json.toString());
}
void saveCreateHistory(String url) {
var list = getCreateHistory() ?? [];
list.remove(url);
+7
View File
@@ -118,5 +118,12 @@ const enUS = {
'fileSelectedSize': 'Size: ',
'httpHeaderName': 'Header Name',
'httpHeaderValue': 'Header Value',
'login': 'Login',
'username_required': 'Please enter your username',
'password_required': 'Please enter your password',
'login_success': 'Login successful',
'login_failed': 'Login failed, please check your username and password',
'login_failed_network':
'Login failed, please check your network connection',
},
};
+6
View File
@@ -116,5 +116,11 @@ const zhCN = {
'fileSelectedSize': '大小:',
'httpHeaderName': '请求头名称',
'httpHeaderValue': '请求头值',
'login': '登录',
'username_required': '请输入用户名',
'password_required': '请输入密码',
'login_success': '登录成功',
'login_failed': '登录失败,请检查用户名和密码',
'login_failed_network': '登录失败,请检查网络连接',
}
};
+40
View File
@@ -448,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b
url: "https://pub.dev"
source: hosted
version: "2.0.17"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -803,6 +811,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1329,6 +1345,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
description:
+2
View File
@@ -77,6 +77,7 @@ dependencies:
git:
url: https://github.com/hui-z/flutter_install_plugin.git
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
flutter_svg: ^2.0.17
dependency_overrides:
permission_handler_windows:
git:
@@ -113,6 +114,7 @@ flutter:
assets:
- assets/tray_icon/
- assets/extension/
- assets/icon/
# Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host
- assets/exec/