Add Go rewrite with Postgres 17 and Dokploy Docker Compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerAdmin(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin", a.RequireAdmin(AdminIndex(a)))
|
||||
|
||||
mux.HandleFunc("GET /admin/links", a.RequireAdmin(AdminLinksGet(a)))
|
||||
mux.HandleFunc("POST /admin/links", a.RequireAdmin(a.CSRFProtect(AdminLinksPost(a))))
|
||||
|
||||
mux.HandleFunc("GET /admin/renewal", a.RequireAdmin(AdminRenewalGet(a)))
|
||||
mux.HandleFunc("POST /admin/renewal", a.RequireAdmin(a.CSRFProtect(AdminRenewalPost(a))))
|
||||
|
||||
mux.HandleFunc("GET /admin/configs", a.RequireAdmin(AdminConfigsGet(a)))
|
||||
mux.HandleFunc("POST /admin/configs", a.RequireAdmin(a.CSRFProtect(AdminConfigsPost(a))))
|
||||
|
||||
mux.HandleFunc("GET /admin/servers", a.RequireAdmin(AdminServersGet(a)))
|
||||
mux.HandleFunc("POST /admin/servers", a.RequireAdmin(a.CSRFProtect(AdminServersPost(a))))
|
||||
|
||||
mux.HandleFunc("GET /admin/settings", a.RequireAdmin(AdminSettingsGet(a)))
|
||||
mux.HandleFunc("POST /admin/settings", a.RequireAdmin(a.CSRFProtect(AdminSettingsPost(a))))
|
||||
}
|
||||
|
||||
// adminLayout carries the data every admin page template needs for the shared
|
||||
// cyber-dark shell (sidebar, topbar, flash banners).
|
||||
type adminLayout struct {
|
||||
Admin *models.User
|
||||
Active string
|
||||
OK string
|
||||
Error string
|
||||
}
|
||||
|
||||
func newAdminLayout(a *web.App, r *http.Request, active string) adminLayout {
|
||||
return adminLayout{Admin: a.CurrentAdmin(r), Active: active}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
type configRow struct {
|
||||
models.ShareCreation
|
||||
LinkID int
|
||||
LinkToken string
|
||||
}
|
||||
|
||||
type adminConfigsView struct {
|
||||
adminLayout
|
||||
Rows []configRow
|
||||
LinkIDFilter int
|
||||
ServerIDFilter int
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
const adminConfigsMax = 300
|
||||
|
||||
// AdminConfigsGet lists guest-created panel connections (optionally scoped to
|
||||
// one link or server) with a delete action (admin/server_configs.php).
|
||||
func AdminConfigsGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ok, errText := flashFromQuery(r)
|
||||
view := adminConfigsView{
|
||||
adminLayout: newAdminLayout(a, r, "configs"),
|
||||
LinkIDFilter: queryInt(r, "link_id", 0),
|
||||
ServerIDFilter: queryInt(r, "server_id", 0),
|
||||
}
|
||||
view.OK, view.Error = ok, errText
|
||||
|
||||
add := func(linkID int, token string, creations []models.ShareCreation) {
|
||||
for _, c := range creations {
|
||||
if view.ServerIDFilter > 0 && c.ServerID != view.ServerIDFilter {
|
||||
continue
|
||||
}
|
||||
if len(view.Rows) >= adminConfigsMax {
|
||||
view.Truncated = true
|
||||
return
|
||||
}
|
||||
view.Rows = append(view.Rows, configRow{ShareCreation: c, LinkID: linkID, LinkToken: token})
|
||||
}
|
||||
}
|
||||
|
||||
if view.LinkIDFilter > 0 {
|
||||
if link, err := a.Share.LinkByID(ctx, view.LinkIDFilter); err == nil && link != nil {
|
||||
creations, _ := a.Share.ListCreationsForLink(ctx, link.ID)
|
||||
add(link.ID, link.Token, creations)
|
||||
}
|
||||
} else {
|
||||
links, err := a.Share.ListLinks(ctx, 1, 50, "")
|
||||
if err != nil {
|
||||
view.Error = err.Error()
|
||||
}
|
||||
for _, l := range links {
|
||||
creations, _ := a.Share.ListCreationsForLink(ctx, l.ID)
|
||||
add(l.ID, l.Token, creations)
|
||||
if view.Truncated {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.Render(w, r, "page_admin_configs", view)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminConfigsPost deletes a single guest connection (from the panel, then the
|
||||
// DB).
|
||||
func AdminConfigsPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
var okMsg string
|
||||
|
||||
switch r.FormValue("action") {
|
||||
case "delete_creation":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
creationID := formInt64(r, "creation_id", 0)
|
||||
err = a.Share.SoftDeleteCreation(ctx, a.Panel, a.Settings, linkID, creationID)
|
||||
if err == nil {
|
||||
okMsg = "Конфигурация удалена."
|
||||
}
|
||||
default:
|
||||
err = errUnknownAction
|
||||
}
|
||||
|
||||
back := "admin/configs"
|
||||
q := url.Values{}
|
||||
if v := r.FormValue("link_id"); v != "" {
|
||||
q.Set("link_id", v)
|
||||
}
|
||||
if v := r.FormValue("server_id_filter"); v != "" {
|
||||
q.Set("server_id", v)
|
||||
}
|
||||
if len(q) > 0 {
|
||||
back += "?" + q.Encode()
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
redirectWithFlash(w, r, a, back, okMsg, errText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
type adminIndexView struct {
|
||||
adminLayout
|
||||
TotalLinks int
|
||||
ActiveLinks int
|
||||
ExpiredLinks int
|
||||
CleanedLinks int
|
||||
LimitLinks int
|
||||
CodesCount int
|
||||
PanelURL string
|
||||
PanelOK bool
|
||||
PanelErr string
|
||||
Maintenance bool
|
||||
}
|
||||
|
||||
// AdminIndex renders the admin dashboard (admin/index.php).
|
||||
func AdminIndex(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
view := adminIndexView{adminLayout: newAdminLayout(a, r, "index")}
|
||||
|
||||
view.TotalLinks, _ = a.Share.CountLinks(ctx, "")
|
||||
view.ActiveLinks, _ = a.Share.CountLinks(ctx, "active")
|
||||
view.ExpiredLinks, _ = a.Share.CountLinks(ctx, "expired")
|
||||
view.CleanedLinks, _ = a.Share.CountLinks(ctx, "cleaned")
|
||||
view.LimitLinks, _ = a.Share.CountLinks(ctx, "limit")
|
||||
if codes, err := a.Share.ListCodes(ctx); err == nil {
|
||||
view.CodesCount = len(codes)
|
||||
}
|
||||
view.Maintenance = a.Settings.Maintenance(ctx)
|
||||
|
||||
view.PanelURL = a.Settings.PanelURL(ctx)
|
||||
token := a.Settings.PanelToken(ctx)
|
||||
if view.PanelURL != "" && token != "" {
|
||||
servers, err := a.Panel.ListServers(ctx, view.PanelURL, token)
|
||||
if err != nil {
|
||||
view.PanelErr = err.Error()
|
||||
} else {
|
||||
view.PanelOK = true
|
||||
_ = servers
|
||||
}
|
||||
}
|
||||
|
||||
a.Render(w, r, "page_admin_index", view)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func flashFromQuery(r *http.Request) (ok, errText string) {
|
||||
return r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||||
}
|
||||
|
||||
func redirectWithFlash(w http.ResponseWriter, r *http.Request, a *web.App, path string, ok, errText string) {
|
||||
u, _ := url.Parse(a.Cfg.AppURL(path))
|
||||
q := u.Query()
|
||||
if ok != "" {
|
||||
q.Set("ok", ok)
|
||||
}
|
||||
if errText != "" {
|
||||
q.Set("err", errText)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
const adminPerPage = 20
|
||||
|
||||
type linkRow struct {
|
||||
models.ShareLink
|
||||
Status share.LinkStatus
|
||||
ActiveConfigs int
|
||||
}
|
||||
|
||||
type adminLinksView struct {
|
||||
adminLayout
|
||||
Links []linkRow
|
||||
StatusFilter string
|
||||
Page int
|
||||
Total int
|
||||
PerPage int
|
||||
Detail *models.ShareLink
|
||||
DetailStatus share.LinkStatus
|
||||
Creations []models.ShareCreation
|
||||
DeletedCreations []models.ShareCreation
|
||||
AllowedDurations []int
|
||||
AllowedServerIDs []int
|
||||
AllServers []models.ServerInfo
|
||||
}
|
||||
|
||||
// AdminLinksGet lists share links (paginated, filterable by status) and, when
|
||||
// ?id= is present, shows one link's detail with its creations (share_links.php).
|
||||
func AdminLinksGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ok, errText := flashFromQuery(r)
|
||||
view := adminLinksView{
|
||||
adminLayout: newAdminLayout(a, r, "links"),
|
||||
StatusFilter: r.URL.Query().Get("status"),
|
||||
Page: queryInt(r, "page", 1),
|
||||
PerPage: adminPerPage,
|
||||
AllowedDurations: share.AllowedDurations,
|
||||
}
|
||||
view.OK, view.Error = ok, errText
|
||||
if view.Page < 1 {
|
||||
view.Page = 1
|
||||
}
|
||||
|
||||
links, err := a.Share.ListLinks(ctx, view.Page, view.PerPage, view.StatusFilter)
|
||||
if err != nil {
|
||||
view.Error = err.Error()
|
||||
}
|
||||
view.Total, _ = a.Share.CountLinks(ctx, view.StatusFilter)
|
||||
for _, l := range links {
|
||||
creations, _ := a.Share.ListCreationsForLink(ctx, l.ID)
|
||||
view.Links = append(view.Links, linkRow{
|
||||
ShareLink: l,
|
||||
Status: share.ClassifyLinkStatus(l, len(creations) > 0),
|
||||
ActiveConfigs: len(creations),
|
||||
})
|
||||
}
|
||||
|
||||
if id := queryInt(r, "id", 0); id > 0 {
|
||||
if link, err := a.Share.LinkByID(ctx, id); err == nil && link != nil {
|
||||
view.Detail = link
|
||||
creations, _ := a.Share.ListCreationsForLink(ctx, id)
|
||||
view.DetailStatus = share.ClassifyLinkStatus(*link, len(creations) > 0)
|
||||
view.Creations = creations
|
||||
view.DeletedCreations, _ = a.Share.ListDeletedCreationsForLink(ctx, id)
|
||||
view.AllowedServerIDs = share.ParseAllowedServerIDs(link.AllowedServerIDs)
|
||||
view.AllServers = share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
a.Render(w, r, "page_admin_links", view)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFormDate(v string) *time.Time {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{"2006-01-02T15:04", "2006-01-02", time.RFC3339}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminLinksPost handles link create/update/delete/extend/allowed-servers and
|
||||
// per-creation deletion.
|
||||
func AdminLinksPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
action := r.FormValue("action")
|
||||
back := "admin/links"
|
||||
if id := r.FormValue("back_id"); id != "" {
|
||||
back += "?id=" + url.QueryEscape(id)
|
||||
}
|
||||
|
||||
var err error
|
||||
var okMsg string
|
||||
|
||||
switch action {
|
||||
case "create":
|
||||
days := formInt(r, "days", 30)
|
||||
maxUses := formInt(r, "max_uses", 5)
|
||||
subUntil := parseFormDate(r.FormValue("subscription_until"))
|
||||
link, cerr := a.Share.CreateLink(ctx, days, maxUses, subUntil)
|
||||
err = cerr
|
||||
if err == nil {
|
||||
okMsg = "Ссылка создана: #" + itoaHelper(link.ID)
|
||||
}
|
||||
|
||||
case "update":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
validityDays := formInt(r, "validity_days", 30)
|
||||
floating := r.FormValue("floating_expiry") == "1"
|
||||
expiresAt := parseFormDate(r.FormValue("expires_at"))
|
||||
subUntil := parseFormDate(r.FormValue("subscription_until"))
|
||||
maxUses := formInt(r, "max_uses", 5)
|
||||
err = a.Share.UpdateLink(ctx, linkID, validityDays, floating, expiresAt, subUntil, maxUses)
|
||||
if err == nil {
|
||||
okMsg = "Срок ссылки обновлён."
|
||||
}
|
||||
|
||||
case "delete":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
err = a.Share.DeleteLink(ctx, linkID)
|
||||
back = "admin/links"
|
||||
if err == nil {
|
||||
okMsg = "Ссылка удалена."
|
||||
}
|
||||
|
||||
case "extend":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
days := formInt(r, "days", 30)
|
||||
err = a.Share.ExtendLinkByDays(ctx, linkID, days)
|
||||
if err == nil {
|
||||
okMsg = "Ссылка продлена."
|
||||
}
|
||||
|
||||
case "set_allowed_servers":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
_ = r.ParseForm()
|
||||
var ids []int
|
||||
for _, v := range r.Form["server_ids[]"] {
|
||||
if n := formIntFromString(v, -1); n >= 0 {
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
err = a.Share.SetAllowedServerIDs(ctx, linkID, ids)
|
||||
if err == nil {
|
||||
okMsg = "Список серверов обновлён."
|
||||
}
|
||||
|
||||
case "delete_creation":
|
||||
linkID := formInt(r, "link_id", 0)
|
||||
creationID := formInt64(r, "creation_id", 0)
|
||||
err = a.Share.SoftDeleteCreation(ctx, a.Panel, a.Settings, linkID, creationID)
|
||||
if err == nil {
|
||||
okMsg = "Конфигурация удалена."
|
||||
}
|
||||
|
||||
default:
|
||||
err = errUnknownAction
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
redirectWithFlash(w, r, a, back, okMsg, errText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
type adminRenewalView struct {
|
||||
adminLayout
|
||||
Codes []models.RenewalCode
|
||||
}
|
||||
|
||||
// AdminRenewalGet lists renewal codes (admin/renewal_codes.php GET).
|
||||
func AdminRenewalGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, errText := flashFromQuery(r)
|
||||
view := adminRenewalView{adminLayout: newAdminLayout(a, r, "renewal")}
|
||||
view.OK, view.Error = ok, errText
|
||||
codes, err := a.Share.ListCodes(r.Context())
|
||||
if err != nil {
|
||||
view.Error = err.Error()
|
||||
}
|
||||
view.Codes = codes
|
||||
a.Render(w, r, "page_admin_renewal", view)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRenewalPost handles renewal code create/delete (admin/renewal_codes.php
|
||||
// POST).
|
||||
func AdminRenewalPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
var okMsg string
|
||||
|
||||
switch r.FormValue("action") {
|
||||
case "create":
|
||||
code := r.FormValue("code")
|
||||
addDays := formInt(r, "add_days", 30)
|
||||
maxUses := formInt(r, "max_uses", 1)
|
||||
target := strings.TrimSpace(r.FormValue("code_target"))
|
||||
note := r.FormValue("note")
|
||||
expiresAt := parseFormDate(r.FormValue("code_expires_at"))
|
||||
|
||||
var linkID *int
|
||||
if target != "member" {
|
||||
if v := formInt(r, "share_link_id", 0); v > 0 {
|
||||
linkID = &v
|
||||
}
|
||||
}
|
||||
|
||||
created, cerr := a.Share.CreateRenewalCode(ctx, code, addDays, maxUses, linkID, expiresAt, note, target)
|
||||
err = cerr
|
||||
if err == nil {
|
||||
okMsg = "Код создан: " + created.Code
|
||||
}
|
||||
|
||||
case "delete":
|
||||
id := formInt(r, "code_id", 0)
|
||||
err = a.Share.DeleteCode(ctx, id)
|
||||
if err == nil {
|
||||
okMsg = "Код удалён."
|
||||
}
|
||||
|
||||
default:
|
||||
err = errUnknownAction
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
redirectWithFlash(w, r, a, "admin/renewal", okMsg, errText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/settings"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
type serverRow struct {
|
||||
ID int
|
||||
Title string
|
||||
Protocols []string
|
||||
Flag string
|
||||
Speed string
|
||||
Disabled bool
|
||||
PanelName string
|
||||
PanelHost string
|
||||
}
|
||||
|
||||
type adminServersView struct {
|
||||
adminLayout
|
||||
Rows []serverRow
|
||||
AllProtocols []string
|
||||
PanelReachable bool
|
||||
PanelErr string
|
||||
}
|
||||
|
||||
// AdminServersGet lists the known panel servers with their editable labels,
|
||||
// allowed protocols, flag emoji, speed badge and disabled state
|
||||
// (admin/server_labels.php).
|
||||
func AdminServersGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ok, errText := flashFromQuery(r)
|
||||
view := adminServersView{
|
||||
adminLayout: newAdminLayout(a, r, "servers"),
|
||||
AllProtocols: share.GuestProtocols,
|
||||
}
|
||||
view.OK, view.Error = ok, errText
|
||||
|
||||
labels := a.Settings.ServerLabels(ctx)
|
||||
protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON)
|
||||
flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON)
|
||||
speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON)
|
||||
disabledMap := a.Settings.DisabledServers(ctx)
|
||||
|
||||
baseURL := a.Settings.PanelURL(ctx)
|
||||
token := a.Settings.PanelToken(ctx)
|
||||
panelServers := map[int]struct{ Name, Host string }{}
|
||||
if baseURL != "" && token != "" {
|
||||
list, err := a.Panel.ListServers(ctx, baseURL, token)
|
||||
if err != nil {
|
||||
view.PanelErr = err.Error()
|
||||
} else {
|
||||
view.PanelReachable = true
|
||||
for _, s := range list {
|
||||
panelServers[s.ID] = struct{ Name, Host string }{s.Name, s.Host}
|
||||
if _, known := labels[s.ID]; !known {
|
||||
labels[s.ID] = s.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(labels))
|
||||
for id := range labels {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
for _, id := range ids {
|
||||
row := serverRow{
|
||||
ID: id,
|
||||
Title: labels[id],
|
||||
Protocols: protosMap[id],
|
||||
Flag: flagsMap[id],
|
||||
Speed: speedsMap[id],
|
||||
Disabled: disabledMap[id],
|
||||
}
|
||||
if len(row.Protocols) == 0 {
|
||||
row.Protocols = append([]string(nil), share.DefaultServerProtocols...)
|
||||
}
|
||||
if p, ok := panelServers[id]; ok {
|
||||
row.PanelName, row.PanelHost = p.Name, p.Host
|
||||
}
|
||||
view.Rows = append(view.Rows, row)
|
||||
}
|
||||
|
||||
a.Render(w, r, "page_admin_servers", view)
|
||||
}
|
||||
}
|
||||
|
||||
func protocolSelected(list []string, proto string) bool {
|
||||
for _, p := range list {
|
||||
if p == proto {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func upsertServerLabel(ctx context.Context, a *web.App, id int, title string) error {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.Pool.Exec(ctx, `
|
||||
INSERT INTO panel_server_labels (panel_server_id, title, updated_at)
|
||||
VALUES ($1,$2,NOW())
|
||||
ON CONFLICT (panel_server_id) DO UPDATE SET title=EXCLUDED.title, updated_at=NOW()`,
|
||||
id, title)
|
||||
return err
|
||||
}
|
||||
|
||||
func intMapToJSON[T any](m map[int]T) string {
|
||||
out := make(map[string]T, len(m))
|
||||
for k, v := range m {
|
||||
out[strconv.Itoa(k)] = v
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func intSetToJSON(m map[int]bool) string {
|
||||
ids := make([]int, 0, len(m))
|
||||
for k, v := range m {
|
||||
if v {
|
||||
ids = append(ids, k)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
b, _ := json.Marshal(ids)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func saveServerJSONSettings(ctx context.Context, a *web.App, protosMap map[int][]string, flagsMap, speedsMap map[int]string, disabledSet map[int]bool) error {
|
||||
if err := a.Settings.Set(ctx, settings.KeyProtocolsJSON, intMapToJSON(protosMap)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Settings.Set(ctx, settings.KeyFlagsJSON, intMapToJSON(flagsMap)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Settings.Set(ctx, settings.KeySpeedsJSON, intMapToJSON(speedsMap)); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.Settings.Set(ctx, settings.KeyDisabledServers, intSetToJSON(disabledSet))
|
||||
}
|
||||
|
||||
// AdminServersPost saves the whole server table, adds a new server id, or
|
||||
// removes one server's label row.
|
||||
func AdminServersPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_ = r.ParseForm()
|
||||
var err error
|
||||
var okMsg string
|
||||
|
||||
switch r.FormValue("action") {
|
||||
case "save":
|
||||
protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON)
|
||||
flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON)
|
||||
speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON)
|
||||
disabledSet := a.Settings.DisabledServers(ctx)
|
||||
|
||||
for _, idStr := range r.Form["server_ids"] {
|
||||
id, perr := strconv.Atoi(strings.TrimSpace(idStr))
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
title := r.FormValue(fmt.Sprintf("servers[%d][title]", id))
|
||||
if uerr := upsertServerLabel(ctx, a, id, title); uerr != nil {
|
||||
err = uerr
|
||||
}
|
||||
protos := r.Form[fmt.Sprintf("servers[%d][protocols][]", id)]
|
||||
if len(protos) > 0 {
|
||||
protosMap[id] = protos
|
||||
} else {
|
||||
delete(protosMap, id)
|
||||
}
|
||||
flag := strings.TrimSpace(r.FormValue(fmt.Sprintf("servers[%d][flag]", id)))
|
||||
if flag != "" {
|
||||
flagsMap[id] = flag
|
||||
} else {
|
||||
delete(flagsMap, id)
|
||||
}
|
||||
speed := strings.TrimSpace(r.FormValue(fmt.Sprintf("servers[%d][speed]", id)))
|
||||
if speed != "" {
|
||||
speedsMap[id] = speed
|
||||
} else {
|
||||
delete(speedsMap, id)
|
||||
}
|
||||
if r.FormValue(fmt.Sprintf("servers[%d][disabled]", id)) == "1" {
|
||||
disabledSet[id] = true
|
||||
} else {
|
||||
delete(disabledSet, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = saveServerJSONSettings(ctx, a, protosMap, flagsMap, speedsMap, disabledSet)
|
||||
}
|
||||
if err == nil {
|
||||
okMsg = "Настройки серверов сохранены."
|
||||
}
|
||||
|
||||
case "add":
|
||||
id := formInt(r, "new_server_id", 0)
|
||||
title := r.FormValue("new_server_title")
|
||||
if id <= 0 || strings.TrimSpace(title) == "" {
|
||||
err = errUnknownAction
|
||||
break
|
||||
}
|
||||
err = upsertServerLabel(ctx, a, id, title)
|
||||
if err == nil {
|
||||
okMsg = "Сервер добавлен."
|
||||
}
|
||||
|
||||
case "delete_label":
|
||||
id := formInt(r, "server_id", 0)
|
||||
_, derr := a.Pool.Exec(ctx, `DELETE FROM panel_server_labels WHERE panel_server_id=$1`, id)
|
||||
err = derr
|
||||
if err == nil {
|
||||
protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON)
|
||||
flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON)
|
||||
speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON)
|
||||
disabledSet := a.Settings.DisabledServers(ctx)
|
||||
delete(protosMap, id)
|
||||
delete(flagsMap, id)
|
||||
delete(speedsMap, id)
|
||||
delete(disabledSet, id)
|
||||
err = saveServerJSONSettings(ctx, a, protosMap, flagsMap, speedsMap, disabledSet)
|
||||
}
|
||||
if err == nil {
|
||||
okMsg = "Сервер удалён из списка."
|
||||
}
|
||||
|
||||
default:
|
||||
err = errUnknownAction
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
redirectWithFlash(w, r, a, "admin/servers", okMsg, errText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/panel"
|
||||
"amnezia-share/internal/settings"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
type adminSettingsView struct {
|
||||
adminLayout
|
||||
PanelURL string
|
||||
APIToken string
|
||||
ServerLabelsJSON string
|
||||
TrafficNotices string
|
||||
Maintenance bool
|
||||
PocketURL string
|
||||
PocketClientID string
|
||||
PocketSecret string
|
||||
MemberDays string
|
||||
MemberMaxUses string
|
||||
TestResult string
|
||||
TestError string
|
||||
}
|
||||
|
||||
func loadAdminSettingsView(a *web.App, r *http.Request) adminSettingsView {
|
||||
ctx := r.Context()
|
||||
vals, _ := a.Settings.GetMany(ctx,
|
||||
settings.KeyPanelURL, settings.KeyAPIToken, settings.KeyServerLabelsJSON,
|
||||
settings.KeyTrafficNotices, settings.KeyMaintenance,
|
||||
settings.KeyPocketURL, settings.KeyPocketClientID, settings.KeyPocketSecret,
|
||||
settings.KeyMemberDays, settings.KeyMemberMaxUses,
|
||||
)
|
||||
return adminSettingsView{
|
||||
adminLayout: newAdminLayout(a, r, "settings"),
|
||||
PanelURL: vals[settings.KeyPanelURL],
|
||||
APIToken: vals[settings.KeyAPIToken],
|
||||
ServerLabelsJSON: vals[settings.KeyServerLabelsJSON],
|
||||
TrafficNotices: vals[settings.KeyTrafficNotices],
|
||||
Maintenance: vals[settings.KeyMaintenance] == "1",
|
||||
PocketURL: vals[settings.KeyPocketURL],
|
||||
PocketClientID: vals[settings.KeyPocketClientID],
|
||||
PocketSecret: vals[settings.KeyPocketSecret],
|
||||
MemberDays: vals[settings.KeyMemberDays],
|
||||
MemberMaxUses: vals[settings.KeyMemberMaxUses],
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSettingsGet renders the settings form (admin/settings.php GET).
|
||||
func AdminSettingsGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
view := loadAdminSettingsView(a, r)
|
||||
view.OK, view.Error = flashFromQuery(r)
|
||||
a.Render(w, r, "page_admin_settings", view)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSettingsPost saves settings, or runs a connectivity test against the
|
||||
// Amnezia panel without saving (admin/settings.php POST).
|
||||
func AdminSettingsPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if r.FormValue("action") == "test" {
|
||||
view := loadAdminSettingsView(a, r)
|
||||
baseURL := strings.TrimSpace(r.FormValue("panel_url"))
|
||||
token := panel.NormalizeToken(r.FormValue("api_token"))
|
||||
if baseURL == "" || token == "" {
|
||||
view.TestError = "Укажите URL панели и токен."
|
||||
} else if !panel.TokenOK(token) {
|
||||
view.TestError = "Токен имеет неверный формат (ожидается awp_...)."
|
||||
} else {
|
||||
servers, err := a.Panel.ListServers(ctx, baseURL, token)
|
||||
if err != nil {
|
||||
view.TestError = err.Error()
|
||||
} else {
|
||||
view.TestResult = fmt.Sprintf("Соединение установлено. Серверов обнаружено: %d.", len(servers))
|
||||
}
|
||||
}
|
||||
view.PanelURL = baseURL
|
||||
view.APIToken = r.FormValue("api_token")
|
||||
a.Render(w, r, "page_admin_settings", view)
|
||||
return
|
||||
}
|
||||
|
||||
set := func(key, value string) error { return a.Settings.Set(ctx, key, value) }
|
||||
maintenance := "0"
|
||||
if r.FormValue("maintenance") == "1" {
|
||||
maintenance = "1"
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, kv := range []struct{ key, value string }{
|
||||
{settings.KeyPanelURL, strings.TrimSpace(r.FormValue("panel_url"))},
|
||||
{settings.KeyAPIToken, panel.NormalizeToken(r.FormValue("api_token"))},
|
||||
{settings.KeyServerLabelsJSON, r.FormValue("server_labels_json")},
|
||||
{settings.KeyTrafficNotices, r.FormValue("traffic_notices_json")},
|
||||
{settings.KeyMaintenance, maintenance},
|
||||
{settings.KeyPocketURL, strings.TrimSpace(r.FormValue("pocket_url"))},
|
||||
{settings.KeyPocketClientID, strings.TrimSpace(r.FormValue("pocket_client_id"))},
|
||||
{settings.KeyPocketSecret, strings.TrimSpace(r.FormValue("pocket_secret"))},
|
||||
{settings.KeyMemberDays, strings.TrimSpace(r.FormValue("member_days"))},
|
||||
{settings.KeyMemberMaxUses, strings.TrimSpace(r.FormValue("member_max_uses"))},
|
||||
} {
|
||||
if serr := set(kv.key, kv.value); serr != nil {
|
||||
err = serr
|
||||
}
|
||||
}
|
||||
a.Panel.ClearCache()
|
||||
|
||||
okMsg := "Настройки сохранены."
|
||||
errText := ""
|
||||
if err != nil {
|
||||
okMsg = ""
|
||||
errText = err.Error()
|
||||
}
|
||||
redirectWithFlash(w, r, a, "admin/settings", okMsg, errText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/i18n"
|
||||
"amnezia-share/internal/member"
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerCabinet(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /cabinet", a.RequireMember(CabinetGet(a)))
|
||||
mux.HandleFunc("POST /cabinet", a.RequireMember(a.CSRFProtect(CabinetPost(a))))
|
||||
mux.HandleFunc("GET /cabinet/login", CabinetLoginForm(a))
|
||||
mux.HandleFunc("POST /cabinet/login", a.CSRFProtect(CabinetLoginSubmit(a)))
|
||||
mux.HandleFunc("GET /cabinet/register", CabinetRegisterForm(a))
|
||||
mux.HandleFunc("POST /cabinet/register", a.CSRFProtect(CabinetRegisterSubmit(a)))
|
||||
mux.HandleFunc("GET /cabinet/logout", CabinetLogout(a))
|
||||
mux.HandleFunc("GET /cabinet/servers", CabinetServers(a))
|
||||
mux.HandleFunc("GET /cabinet/download", CabinetDownload(a))
|
||||
}
|
||||
|
||||
type cabinetView struct {
|
||||
Member *models.Member
|
||||
Sub *models.MemberSubscription
|
||||
SubLabel string
|
||||
ConfigsRemaining int
|
||||
Servers []models.ServerInfo
|
||||
Bundles []share.Bundle
|
||||
Maintenance bool
|
||||
Expired bool
|
||||
LimitReached bool
|
||||
OK string
|
||||
Error string
|
||||
}
|
||||
|
||||
func renderCabinet(a *web.App, w http.ResponseWriter, r *http.Request, okMsg, errText string) {
|
||||
ctx := r.Context()
|
||||
m := a.CurrentMember(r)
|
||||
sub, err := a.Member.EnsureSubscription(ctx, a.Settings, m.ID)
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
bundles, _ := a.Member.Bundles(ctx, m.ID)
|
||||
servers := share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx))
|
||||
|
||||
view := cabinetView{
|
||||
Member: m,
|
||||
Sub: sub,
|
||||
Servers: servers,
|
||||
Bundles: bundles,
|
||||
Maintenance: a.Settings.Maintenance(ctx),
|
||||
OK: okMsg,
|
||||
Error: errText,
|
||||
}
|
||||
if sub != nil {
|
||||
view.Expired = member.SubscriptionExpired(*sub)
|
||||
view.LimitReached = sub.ConfigCount >= sub.MaxConfigs
|
||||
view.SubLabel = member.SubscriptionExpiresLabel(*sub)
|
||||
view.ConfigsRemaining = sub.MaxConfigs - sub.ConfigCount
|
||||
}
|
||||
a.Render(w, r, "page_cabinet", view)
|
||||
}
|
||||
|
||||
// CabinetGet renders the member portal dashboard (cabinet.php GET).
|
||||
func CabinetGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
renderCabinet(a, w, r, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetPost handles member actions: create / migrate / renew / delete
|
||||
// (cabinet.php POST).
|
||||
func CabinetPost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
m := a.CurrentMember(r)
|
||||
lang := i18n.Resolve(r, a.Sessions)
|
||||
maintenance := a.Settings.Maintenance(ctx)
|
||||
|
||||
var actionErr error
|
||||
var okMsg string
|
||||
switch r.FormValue("action") {
|
||||
case "create":
|
||||
if maintenance {
|
||||
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
|
||||
break
|
||||
}
|
||||
serverID := formInt(r, "server_id", -1)
|
||||
protocol := strings.TrimSpace(r.FormValue("protocol"))
|
||||
_, err := a.Member.TryAddConfig(ctx, a.Panel, a.Settings, m.ID, serverID, protocol)
|
||||
if err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
okMsg = "Готово! Конфигурация создана."
|
||||
case "migrate":
|
||||
if maintenance {
|
||||
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
|
||||
break
|
||||
}
|
||||
configID := formInt64(r, "config_id", 0)
|
||||
newServerID := formInt(r, "new_server_id", -1)
|
||||
newProtocol := strings.TrimSpace(r.FormValue("new_protocol"))
|
||||
if err := a.Member.TryMigrate(ctx, a.Panel, a.Settings, m.ID, configID, newServerID, newProtocol); err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
okMsg = i18n.T(lang, "ok_migrated", nil)
|
||||
case "renew":
|
||||
code := r.FormValue("code")
|
||||
res, err := a.Member.TryRedeemRenewal(ctx, m.ID, code)
|
||||
if err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
until := ""
|
||||
if res.Sub != nil {
|
||||
until = member.SubscriptionExpiresLabel(*res.Sub)
|
||||
}
|
||||
okMsg = i18n.T(lang, "ok_renewed", map[string]string{"days": fmt.Sprint(res.AddDays), "until": until})
|
||||
case "delete":
|
||||
configID := formInt64(r, "config_id", 0)
|
||||
if err := a.Member.SoftDelete(ctx, a.Panel, a.Settings, m.ID, configID); err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
okMsg = "Конфигурация удалена."
|
||||
default:
|
||||
actionErr = errors.New("Неизвестное действие.")
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if actionErr != nil {
|
||||
errText = i18n.TranslateGuestError(lang, actionErr.Error())
|
||||
}
|
||||
if wantsJSON(r) {
|
||||
if errText != "" {
|
||||
writeJSONError(w, http.StatusBadRequest, errText)
|
||||
return
|
||||
}
|
||||
writeJSONOK(w, map[string]any{"message": okMsg})
|
||||
return
|
||||
}
|
||||
renderCabinet(a, w, r, okMsg, errText)
|
||||
}
|
||||
}
|
||||
|
||||
type cabinetAuthView struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// CabinetLoginForm renders the member login form.
|
||||
func CabinetLoginForm(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.CurrentMember(r) != nil {
|
||||
redirectTo(w, r, a, "cabinet")
|
||||
return
|
||||
}
|
||||
a.Render(w, r, "page_cabinet_auth", cabinetAuthView{})
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetLoginSubmit verifies member credentials and signs them in.
|
||||
func CabinetLoginSubmit(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
m, err := a.Member.Login(ctx, username, password)
|
||||
if err != nil {
|
||||
a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = a.Sessions.RenewToken(ctx)
|
||||
a.Sessions.Put(ctx, web.SessionMemberID, m.ID)
|
||||
redirectTo(w, r, a, "cabinet")
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetRegisterForm renders the member self-registration form.
|
||||
func CabinetRegisterForm(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.CurrentMember(r) != nil {
|
||||
redirectTo(w, r, a, "cabinet")
|
||||
return
|
||||
}
|
||||
a.Render(w, r, "page_cabinet_auth", cabinetAuthView{})
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetRegisterSubmit creates a new member account and signs them in.
|
||||
func CabinetRegisterSubmit(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
m, err := a.Member.Register(ctx, username, password, email)
|
||||
if err != nil {
|
||||
a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := a.Member.EnsureSubscription(ctx, a.Settings, m.ID); err != nil {
|
||||
a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = a.Sessions.RenewToken(ctx)
|
||||
a.Sessions.Put(ctx, web.SessionMemberID, m.ID)
|
||||
redirectTo(w, r, a, "cabinet")
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetLogout clears the member session.
|
||||
func CabinetLogout(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
a.Sessions.Remove(r.Context(), web.SessionMemberID)
|
||||
_ = a.Sessions.RenewToken(r.Context())
|
||||
redirectTo(w, r, a, "cabinet/login")
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetServers returns the server catalog as JSON for the member portal.
|
||||
func CabinetServers(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
servers := share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx))
|
||||
writeJSONOK(w, map[string]any{"servers": servers})
|
||||
}
|
||||
}
|
||||
|
||||
// CabinetDownload streams a single downloadable artifact for one of the
|
||||
// member's configs.
|
||||
func CabinetDownload(a *web.App) http.HandlerFunc {
|
||||
return a.RequireMember(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
m := a.CurrentMember(r)
|
||||
configID := int64(queryInt(r, "cre", 0))
|
||||
part := r.URL.Query().Get("part")
|
||||
fp, err := a.Member.DownloadPayload(ctx, m.ID, configID, part)
|
||||
if err != nil || fp == nil {
|
||||
http.Error(w, "Файл не найден.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
serveFilePart(w, fp)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package handlers implements the HTTP handler functions for the Amnezia Share
|
||||
// Panel web application. Handlers are grouped by domain across several files
|
||||
// (health.go, install.go, login.go, oidc.go, share.go, cabinet.go, faq.go,
|
||||
// admin_*.go) but all live in this single package so they can freely share
|
||||
// small helpers declared in this file.
|
||||
//
|
||||
// The package registers itself onto internal/web via web.RegisterRoutes from
|
||||
// init(), rather than internal/web importing this package directly — that
|
||||
// would create an import cycle, since this package needs *web.App.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
// errUnknownAction is returned by admin/guest POST dispatchers for an
|
||||
// unrecognized "action" form value.
|
||||
var errUnknownAction = errors.New("Неизвестное действие.")
|
||||
|
||||
func itoaHelper(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
func formIntFromString(v string, def int) int {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func init() {
|
||||
web.RegisterRoutes(register)
|
||||
}
|
||||
|
||||
// register wires every route of the application onto mux.
|
||||
func register(a *web.App, mux *http.ServeMux) {
|
||||
registerHealth(a, mux)
|
||||
registerInstall(a, mux)
|
||||
registerLogin(a, mux)
|
||||
registerOIDC(a, mux)
|
||||
registerShare(a, mux)
|
||||
registerCabinet(a, mux)
|
||||
registerFAQ(a, mux)
|
||||
registerAdmin(a, mux)
|
||||
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.CurrentAdmin(r) != nil {
|
||||
http.Redirect(w, r, a.Cfg.AppURL("admin"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
count, err := a.Auth.AdminCount(r.Context())
|
||||
if err != nil || count == 0 {
|
||||
http.Redirect(w, r, a.Cfg.AppURL("install"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, a.Cfg.AppURL("login"), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func redirectTo(w http.ResponseWriter, r *http.Request, a *web.App, path string) {
|
||||
http.Redirect(w, r, a.Cfg.AppURL(path), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
return r.Header.Get("X-Share-Async") == "1" || strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||
}
|
||||
|
||||
func formInt(r *http.Request, key string, def int) int {
|
||||
v := strings.TrimSpace(r.FormValue(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formInt64(r *http.Request, key string, def int64) int64 {
|
||||
v := strings.TrimSpace(r.FormValue(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, def int) int {
|
||||
v := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func errMsg(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error": msg})
|
||||
}
|
||||
|
||||
func writeJSONOK(w http.ResponseWriter, extra map[string]any) {
|
||||
if extra == nil {
|
||||
extra = map[string]any{}
|
||||
}
|
||||
extra["ok"] = true
|
||||
writeJSON(w, http.StatusOK, extra)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"amnezia-share/internal/i18n"
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerFAQ(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /faq", FAQ(a))
|
||||
mux.HandleFunc("GET /rules", Rules(a))
|
||||
mux.HandleFunc("GET /status", Status(a))
|
||||
}
|
||||
|
||||
type faqEntry struct {
|
||||
Question string
|
||||
Answer string
|
||||
}
|
||||
|
||||
type faqView struct {
|
||||
Items []faqEntry
|
||||
}
|
||||
|
||||
// FAQ renders the guest-facing FAQ page (faq.php).
|
||||
func FAQ(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := i18n.Resolve(r, a.Sessions)
|
||||
items := make([]faqEntry, 0, len(i18n.FAQItems))
|
||||
for _, it := range i18n.FAQItems {
|
||||
items = append(items, faqEntry{
|
||||
Question: i18n.T(lang, it.QuestionKey, nil),
|
||||
Answer: i18n.T(lang, it.AnswerKey, nil),
|
||||
})
|
||||
}
|
||||
a.Render(w, r, "page_faq", faqView{Items: items})
|
||||
}
|
||||
}
|
||||
|
||||
// Rules renders the acceptable-use rules page (rules.php).
|
||||
func Rules(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
a.Render(w, r, "page_rules", nil)
|
||||
}
|
||||
}
|
||||
|
||||
type serverStatus struct {
|
||||
models.ServerInfo
|
||||
Alive bool
|
||||
Ms int
|
||||
Err string
|
||||
}
|
||||
|
||||
type statusView struct {
|
||||
Servers []serverStatus
|
||||
}
|
||||
|
||||
// Status pings every configured server and reports online/offline (status.php).
|
||||
func Status(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
labels := a.Settings.ServerLabels(ctx)
|
||||
servers := share.BuildGuestServers(ctx, a.Settings, labels)
|
||||
baseURL := a.Settings.PanelURL(ctx)
|
||||
token := a.Settings.PanelToken(ctx)
|
||||
|
||||
list := make([]serverStatus, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
st := serverStatus{ServerInfo: s}
|
||||
if baseURL != "" && token != "" {
|
||||
st.Alive, st.Ms, st.Err = a.Panel.Ping(ctx, baseURL, token, s.ID)
|
||||
}
|
||||
list = append(list, st)
|
||||
}
|
||||
a.Render(w, r, "page_status", statusView{Servers: list})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerHealth(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /health", Health(a))
|
||||
}
|
||||
|
||||
// Health reports basic liveness plus a DB ping, mirroring health.php.
|
||||
func Health(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dbOK := true
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Pool.Ping(ctx); err != nil {
|
||||
dbOK = false
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !dbOK {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"ok": dbOK,
|
||||
"db": dbOK,
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerInstall(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /install", InstallForm(a))
|
||||
mux.HandleFunc("POST /install", a.CSRFProtect(InstallSubmit(a)))
|
||||
}
|
||||
|
||||
type installView struct {
|
||||
Error string
|
||||
Installed bool
|
||||
DBError string
|
||||
}
|
||||
|
||||
// InstallForm renders the single-admin registration form (install.php).
|
||||
func InstallForm(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.CurrentAdmin(r) != nil {
|
||||
redirectTo(w, r, a, "admin")
|
||||
return
|
||||
}
|
||||
count, err := a.Auth.AdminCount(r.Context())
|
||||
view := installView{}
|
||||
if err != nil {
|
||||
view.DBError = "Нет связи с базой данных. Проверьте DATABASE_URL и миграции."
|
||||
} else if count > 0 {
|
||||
view.Installed = true
|
||||
}
|
||||
a.Render(w, r, "page_install", view)
|
||||
}
|
||||
}
|
||||
|
||||
// InstallSubmit creates the sole administrator account (install.php POST).
|
||||
func InstallSubmit(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
count, err := a.Auth.AdminCount(ctx)
|
||||
if err != nil {
|
||||
a.Render(w, r, "page_install", installView{DBError: "Нет связи с базой данных."})
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
a.Render(w, r, "page_install", installView{Installed: true})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
p1 := r.FormValue("password")
|
||||
p2 := r.FormValue("password2")
|
||||
if p1 != p2 {
|
||||
a.Render(w, r, "page_install", installView{Error: "Пароли не совпадают."})
|
||||
return
|
||||
}
|
||||
|
||||
admin, err := a.Auth.RegisterFirstAdmin(ctx, username, p1)
|
||||
if err != nil {
|
||||
a.Render(w, r, "page_install", installView{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_ = a.Sessions.RenewToken(ctx)
|
||||
a.Sessions.Put(ctx, web.SessionAdminID, admin.ID)
|
||||
redirectTo(w, r, a, "admin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/auth"
|
||||
"amnezia-share/internal/settings"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerLogin(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /login", LoginForm(a))
|
||||
mux.HandleFunc("POST /login", a.CSRFProtect(LoginSubmit(a)))
|
||||
mux.HandleFunc("GET /logout", Logout(a))
|
||||
}
|
||||
|
||||
// oidcConfigFromSettings loads the Pocket ID OIDC settings (shared by login.go
|
||||
// and oidc.go).
|
||||
func oidcConfigFromSettings(a *web.App, r *http.Request) auth.OIDCConfig {
|
||||
ctx := r.Context()
|
||||
vals, _ := a.Settings.GetMany(ctx, settings.KeyPocketURL, settings.KeyPocketClientID, settings.KeyPocketSecret)
|
||||
return auth.OIDCConfig{
|
||||
URL: vals[settings.KeyPocketURL],
|
||||
ClientID: vals[settings.KeyPocketClientID],
|
||||
ClientSecret: vals[settings.KeyPocketSecret],
|
||||
}
|
||||
}
|
||||
|
||||
type loginView struct {
|
||||
Error string
|
||||
PocketEnabled bool
|
||||
NoAdmins bool
|
||||
}
|
||||
|
||||
// LoginForm renders the admin login page, or starts the Pocket ID SSO flow when
|
||||
// called as GET /login?oidc=1 (login.php).
|
||||
func LoginForm(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if a.CurrentAdmin(r) != nil {
|
||||
redirectTo(w, r, a, "admin")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := oidcConfigFromSettings(a, r)
|
||||
pocketEnabled := cfg.Configured()
|
||||
|
||||
if pocketEnabled && r.URL.Query().Has("oidc") {
|
||||
state, err := auth.GenerateState()
|
||||
if err != nil {
|
||||
http.Error(w, "не удалось начать вход", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
verifier, challenge, err := auth.GeneratePKCE()
|
||||
if err != nil {
|
||||
http.Error(w, "не удалось начать вход", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Sessions.Put(ctx, "oidc_state", state)
|
||||
a.Sessions.Put(ctx, "oidc_verifier", verifier)
|
||||
redirectURI := a.AbsoluteURL(r, "oidc/callback")
|
||||
http.Redirect(w, r, auth.AuthURL(cfg, redirectURI, state, challenge), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
count, _ := a.Auth.AdminCount(ctx)
|
||||
a.Render(w, r, "page_login", loginView{PocketEnabled: pocketEnabled, NoAdmins: count == 0})
|
||||
}
|
||||
}
|
||||
|
||||
// LoginSubmit verifies username/password and signs the admin in (login.php POST).
|
||||
func LoginSubmit(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
admin, err := a.Auth.LoginAdmin(ctx, username, password)
|
||||
if err != nil {
|
||||
cfg := oidcConfigFromSettings(a, r)
|
||||
count, _ := a.Auth.AdminCount(ctx)
|
||||
a.Render(w, r, "page_login", loginView{Error: err.Error(), PocketEnabled: cfg.Configured(), NoAdmins: count == 0})
|
||||
return
|
||||
}
|
||||
|
||||
_ = a.Sessions.RenewToken(ctx)
|
||||
a.Sessions.Put(ctx, web.SessionAdminID, admin.ID)
|
||||
redirectTo(w, r, a, "admin")
|
||||
}
|
||||
}
|
||||
|
||||
// Logout clears the admin session (logout.php).
|
||||
func Logout(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
a.Sessions.Remove(r.Context(), web.SessionAdminID)
|
||||
_ = a.Sessions.RenewToken(r.Context())
|
||||
redirectTo(w, r, a, "login")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"amnezia-share/internal/auth"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerOIDC(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /oidc/callback", OIDCCallback(a))
|
||||
}
|
||||
|
||||
// OIDCCallback completes the Pocket ID authorization-code + PKCE flow, matching
|
||||
// or auto-provisioning the local admin account (oidc_callback.php).
|
||||
func OIDCCallback(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query()
|
||||
|
||||
if errParam := q.Get("error"); errParam != "" {
|
||||
renderOIDCError(a, w, r, "Pocket ID вернул ошибку: "+errParam)
|
||||
return
|
||||
}
|
||||
|
||||
state := q.Get("state")
|
||||
code := q.Get("code")
|
||||
wantState := a.Sessions.GetString(ctx, "oidc_state")
|
||||
verifier := a.Sessions.GetString(ctx, "oidc_verifier")
|
||||
a.Sessions.Remove(ctx, "oidc_state")
|
||||
a.Sessions.Remove(ctx, "oidc_verifier")
|
||||
|
||||
if code == "" || wantState == "" || state != wantState {
|
||||
renderOIDCError(a, w, r, "Недействительный ответ от Pocket ID. Попробуйте войти ещё раз.")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := oidcConfigFromSettings(a, r)
|
||||
if !cfg.Configured() {
|
||||
renderOIDCError(a, w, r, "Вход через Pocket ID не настроен.")
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := a.AbsoluteURL(r, "oidc/callback")
|
||||
info, err := auth.ExchangeAndUserinfo(ctx, cfg, code, redirectURI, verifier)
|
||||
if err != nil {
|
||||
renderOIDCError(a, w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
admin, err := a.Auth.FindOrCreateAdminFromOIDC(ctx, info)
|
||||
if err != nil {
|
||||
renderOIDCError(a, w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = a.Sessions.RenewToken(ctx)
|
||||
a.Sessions.Put(ctx, web.SessionAdminID, admin.ID)
|
||||
redirectTo(w, r, a, "admin")
|
||||
}
|
||||
}
|
||||
|
||||
func renderOIDCError(a *web.App, w http.ResponseWriter, r *http.Request, msg string) {
|
||||
a.Render(w, r, "page_login", loginView{Error: msg, PocketEnabled: oidcConfigFromSettings(a, r).Configured()})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"amnezia-share/internal/config"
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
// TestRenderAllPages executes every page template against a realistic view
|
||||
// struct (as built by the real handlers) without needing a database. It
|
||||
// catches execution-time template errors (bad field/method names, nil
|
||||
// dereferences) that plain `go build` and template parsing cannot catch.
|
||||
func TestRenderAllPages(t *testing.T) {
|
||||
tmpl, err := web.LoadTemplates(config.Config{WebDir: "../../../web"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTemplates: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
future := now.Add(48 * time.Hour)
|
||||
note := "test note"
|
||||
linkID := 7
|
||||
email := "user@example.com"
|
||||
validityDays := 30
|
||||
|
||||
serverInfo := models.ServerInfo{ID: 1, Label: "Germany", Protocols: []string{"wireguard", "awg2"}, Flag: "de", Speed: "1 Gbps"}
|
||||
|
||||
sampleLink := models.ShareLink{
|
||||
ID: 1, Token: "abc123", MaxUses: 5, UseCount: 2, CreatedAt: now,
|
||||
ExpiresAt: &future, ValidityDays: &validityDays,
|
||||
AllowedServerIDs: json.RawMessage(`[1,2]`),
|
||||
}
|
||||
|
||||
bundle := share.Bundle{
|
||||
Base: "peer1",
|
||||
Conf: &share.FilePart{Filename: "peer1.conf", Body: "conf-body", Mime: "text/plain"},
|
||||
Vpn: &share.FilePart{Filename: "peer1.vpn", Body: "vpn-body", Mime: "text/plain"},
|
||||
CreationID: 42,
|
||||
CreatedAt: now,
|
||||
Protocol: "wireguard",
|
||||
ConnectionName: "peer1",
|
||||
ServerID: 1,
|
||||
}
|
||||
|
||||
adminUser := &models.User{ID: 1, Username: "admin"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"page_install", installView{}},
|
||||
{"page_install", installView{Installed: true}},
|
||||
{"page_install", installView{Error: "test error"}},
|
||||
{"page_login", loginView{PocketEnabled: true, NoAdmins: false}},
|
||||
{"page_login", loginView{Error: "bad creds", NoAdmins: true}},
|
||||
{"page_cabinet_auth", cabinetAuthView{}},
|
||||
{"page_cabinet_auth", cabinetAuthView{Error: "bad creds"}},
|
||||
{"page_cabinet", cabinetView{
|
||||
Member: &models.Member{ID: 1, Username: "member1", Email: &email, CreatedAt: now},
|
||||
Sub: &models.MemberSubscription{MemberID: 1, ExpiresAt: &future, MaxConfigs: 5, ConfigCount: 2},
|
||||
SubLabel: "до " + future.Format("2006-01-02"),
|
||||
ConfigsRemaining: 3,
|
||||
Servers: []models.ServerInfo{serverInfo},
|
||||
Bundles: []share.Bundle{bundle},
|
||||
OK: "ok message",
|
||||
}},
|
||||
{"page_cabinet", cabinetView{
|
||||
Member: &models.Member{ID: 1, Username: "member1", CreatedAt: now},
|
||||
Error: "some error",
|
||||
}},
|
||||
{"page_share", shareView{Token: "abc123", NotFound: true}},
|
||||
{"page_share", shareView{
|
||||
Token: "abc123",
|
||||
Link: &sampleLink,
|
||||
Servers: []models.ServerInfo{serverInfo},
|
||||
Bundles: []share.Bundle{bundle},
|
||||
OK: "created",
|
||||
}},
|
||||
{"page_share", shareView{
|
||||
Token: "abc123",
|
||||
Link: &sampleLink,
|
||||
Servers: []models.ServerInfo{serverInfo},
|
||||
Expired: true,
|
||||
LimitReached: true,
|
||||
Error: "some error",
|
||||
}},
|
||||
{"page_faq", faqView{Items: []faqEntry{{Question: "Q1", Answer: "A1"}}}},
|
||||
{"page_rules", nil},
|
||||
{"page_status", statusView{Servers: []serverStatus{{ServerInfo: serverInfo, Alive: true, Ms: 42}, {ServerInfo: serverInfo, Alive: false, Err: "timeout"}}}},
|
||||
{"page_admin_index", adminIndexView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "index"},
|
||||
TotalLinks: 10, ActiveLinks: 5, ExpiredLinks: 2, CleanedLinks: 1, LimitLinks: 2,
|
||||
CodesCount: 3, PanelURL: "https://panel.example.com", PanelOK: true, Maintenance: false,
|
||||
}},
|
||||
{"page_admin_index", adminIndexView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "index"},
|
||||
PanelErr: "connection refused",
|
||||
}},
|
||||
{"page_admin_links", adminLinksView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "links", OK: "saved"},
|
||||
StatusFilter: "active",
|
||||
Page: 1,
|
||||
Total: 1,
|
||||
PerPage: 20,
|
||||
AllowedDurations: []int{7, 30, 90},
|
||||
Links: []linkRow{
|
||||
{ShareLink: sampleLink, Status: share.StatusActive, ActiveConfigs: 2},
|
||||
},
|
||||
Detail: &sampleLink,
|
||||
DetailStatus: share.StatusActive,
|
||||
Creations: []models.ShareCreation{{ID: 42, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer1", CreatedAt: now}},
|
||||
DeletedCreations: []models.ShareCreation{{ID: 41, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer0", CreatedAt: now}},
|
||||
AllowedServerIDs: []int{1, 2},
|
||||
AllServers: []models.ServerInfo{serverInfo},
|
||||
}},
|
||||
{"page_admin_links", adminLinksView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "links"},
|
||||
PerPage: 20,
|
||||
}},
|
||||
{"page_admin_renewal", adminRenewalView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "renewal"},
|
||||
Codes: []models.RenewalCode{
|
||||
{ID: 1, Code: "ABCD1234", AddDays: 30, MaxUses: 1, UseCount: 0, ShareLinkID: &linkID, CodeExpiresAt: &future, Note: ¬e, CodeTarget: "guest", CreatedAt: now},
|
||||
{ID: 2, Code: "WXYZ9999", AddDays: 30, MaxUses: 5, UseCount: 1, CodeTarget: "member", CreatedAt: now},
|
||||
},
|
||||
}},
|
||||
{"page_admin_renewal", adminRenewalView{adminLayout: adminLayout{Admin: adminUser, Active: "renewal"}}},
|
||||
{"page_admin_configs", adminConfigsView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "configs"},
|
||||
Rows: []configRow{
|
||||
{ShareCreation: models.ShareCreation{ID: 42, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer1", CreatedAt: now}, LinkID: 1, LinkToken: "abc123"},
|
||||
},
|
||||
LinkIDFilter: 1,
|
||||
Truncated: true,
|
||||
}},
|
||||
{"page_admin_configs", adminConfigsView{adminLayout: adminLayout{Admin: adminUser, Active: "configs"}}},
|
||||
{"page_admin_servers", adminServersView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "servers"},
|
||||
AllProtocols: share.GuestProtocols,
|
||||
Rows: []serverRow{
|
||||
{ID: 1, Title: "Germany", Protocols: []string{"wireguard", "awg2"}, Flag: "🇩🇪", Speed: "1 Gbps", Disabled: false, PanelName: "de-1", PanelHost: "1.2.3.4"},
|
||||
{ID: 2, Title: "France", Protocols: nil, Disabled: true},
|
||||
},
|
||||
PanelReachable: true,
|
||||
}},
|
||||
{"page_admin_servers", adminServersView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "servers"},
|
||||
AllProtocols: share.GuestProtocols,
|
||||
PanelErr: "connection refused",
|
||||
}},
|
||||
{"page_admin_settings", adminSettingsView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "settings"},
|
||||
PanelURL: "https://panel.example.com",
|
||||
APIToken: "awp_secret",
|
||||
ServerLabelsJSON: `{"1":"Germany"}`,
|
||||
TrafficNotices: `{}`,
|
||||
Maintenance: true,
|
||||
PocketURL: "https://id.example.com",
|
||||
PocketClientID: "client-id",
|
||||
PocketSecret: "secret",
|
||||
MemberDays: "30",
|
||||
MemberMaxUses: "5",
|
||||
TestResult: "Соединение установлено. Серверов обнаружено: 3.",
|
||||
}},
|
||||
{"page_admin_settings", adminSettingsView{
|
||||
adminLayout: adminLayout{Admin: adminUser, Active: "settings"},
|
||||
TestError: "connection refused",
|
||||
}},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
clone, err := tmpl.Clone()
|
||||
if err != nil {
|
||||
t.Fatalf("clone: %v", err)
|
||||
}
|
||||
if err := clone.ExecuteTemplate(io.Discard, c.name, c.data); err != nil {
|
||||
t.Errorf("case %d (%s): execute failed: %v", i, c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"amnezia-share/internal/i18n"
|
||||
"amnezia-share/internal/models"
|
||||
"amnezia-share/internal/share"
|
||||
"amnezia-share/internal/web"
|
||||
)
|
||||
|
||||
func registerShare(a *web.App, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /share", ShareGet(a))
|
||||
mux.HandleFunc("POST /share", a.CSRFProtect(SharePost(a)))
|
||||
mux.HandleFunc("GET /share/servers", ShareServers(a))
|
||||
mux.HandleFunc("GET /share/download", ShareDownload(a))
|
||||
}
|
||||
|
||||
type shareView struct {
|
||||
Token string
|
||||
Link *models.ShareLink
|
||||
Servers []models.ServerInfo
|
||||
Bundles []share.Bundle
|
||||
Maintenance bool
|
||||
Expired bool
|
||||
LimitReached bool
|
||||
NotFound bool
|
||||
OK string
|
||||
Error string
|
||||
}
|
||||
|
||||
func buildBundles(creations []models.ShareCreation) []share.Bundle {
|
||||
out := make([]share.Bundle, 0, len(creations))
|
||||
for _, c := range creations {
|
||||
if c.ResponseJSON == nil || strings.TrimSpace(*c.ResponseJSON) == "" {
|
||||
continue
|
||||
}
|
||||
b := share.BundleFromResponseJSON(c.Protocol, c.ConnectionName, *c.ResponseJSON)
|
||||
b.CreationID = c.ID
|
||||
b.CreatedAt = c.CreatedAt
|
||||
b.ServerID = c.ServerID
|
||||
out = append(out, b)
|
||||
}
|
||||
return share.SortBundlesWireguardFirst(out)
|
||||
}
|
||||
|
||||
func guestServersForLink(a *web.App, r *http.Request, link *models.ShareLink) []models.ServerInfo {
|
||||
ctx := r.Context()
|
||||
labels := a.Settings.ServerLabels(ctx)
|
||||
servers := share.BuildGuestServers(ctx, a.Settings, labels)
|
||||
if link != nil {
|
||||
servers = share.FilterServersForLink(*link, servers)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// renderSharePage re-loads the link fresh (so post-action state is current) and
|
||||
// renders the guest share page.
|
||||
func renderSharePage(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) {
|
||||
ctx := r.Context()
|
||||
view := shareView{Token: token, OK: okMsg, Error: errText}
|
||||
|
||||
link, err := a.Share.LinkByToken(ctx, token)
|
||||
if err != nil || link == nil {
|
||||
view.NotFound = true
|
||||
a.Render(w, r, "page_share", view)
|
||||
return
|
||||
}
|
||||
view.Link = link
|
||||
view.Expired = link.IsExpired() || link.CleanedAt != nil
|
||||
view.LimitReached = link.MaxUses > 0 && link.UseCount >= link.MaxUses
|
||||
view.Maintenance = a.Settings.Maintenance(ctx)
|
||||
view.Servers = guestServersForLink(a, r, link)
|
||||
|
||||
creations, _ := a.Share.ListCreationsForLink(ctx, link.ID)
|
||||
view.Bundles = buildBundles(creations)
|
||||
|
||||
a.Render(w, r, "page_share", view)
|
||||
}
|
||||
|
||||
// ShareGet renders the guest share page (share.php GET).
|
||||
func ShareGet(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if lg := i18n.LangFromRequest(r); lg != "" {
|
||||
i18n.Persist(w, r, a.Sessions, i18n.NormalizeLang(lg), false)
|
||||
}
|
||||
token := r.URL.Query().Get("k")
|
||||
renderSharePage(a, w, r, token, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
func findActiveCreation(creations []models.ShareCreation, id int64) *models.ShareCreation {
|
||||
for i := range creations {
|
||||
if creations[i].ID == id {
|
||||
return &creations[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SharePost handles the guest actions on the share page: create / migrate /
|
||||
// renew (share.php POST). Responds JSON when X-Share-Async:1 is set, otherwise
|
||||
// re-renders the full page (share.php's classic form-post behaviour).
|
||||
func SharePost(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token := r.FormValue("k")
|
||||
lang := i18n.Resolve(r, a.Sessions)
|
||||
|
||||
link, err := a.Share.LinkByToken(ctx, token)
|
||||
if err != nil || link == nil {
|
||||
finishShareAction(a, w, r, token, "", i18n.T(lang, "err_link_not_found", nil))
|
||||
return
|
||||
}
|
||||
|
||||
var actionErr error
|
||||
var okMsg string
|
||||
action := r.FormValue("action")
|
||||
maintenance := a.Settings.Maintenance(ctx)
|
||||
|
||||
switch action {
|
||||
case "create":
|
||||
if maintenance {
|
||||
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
|
||||
break
|
||||
}
|
||||
serverID := formInt(r, "server_id", -1)
|
||||
protocol := strings.TrimSpace(r.FormValue("protocol"))
|
||||
if !share.LinkServerAllowed(*link, serverID) {
|
||||
actionErr = errors.New(i18n.T(lang, "err_pick_server", nil))
|
||||
break
|
||||
}
|
||||
if a.Settings.DisabledServers(ctx)[serverID] {
|
||||
actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil))
|
||||
break
|
||||
}
|
||||
if !share.ServerProtocolAllowed(ctx, a.Settings, serverID, protocol) {
|
||||
actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil))
|
||||
break
|
||||
}
|
||||
_, addErr := a.Share.TryAddConnection(ctx, a.Panel, a.Settings, link, serverID, protocol)
|
||||
if addErr != nil {
|
||||
actionErr = addErr
|
||||
break
|
||||
}
|
||||
fresh, _ := a.Share.LinkByID(ctx, link.ID)
|
||||
remaining, max := 0, 0
|
||||
if fresh != nil {
|
||||
max = fresh.MaxUses
|
||||
remaining = fresh.MaxUses - fresh.UseCount
|
||||
}
|
||||
okMsg = i18n.T(lang, "ok_created", map[string]string{"remaining": fmt.Sprint(remaining), "max": fmt.Sprint(max)})
|
||||
|
||||
case "migrate":
|
||||
if maintenance {
|
||||
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
|
||||
break
|
||||
}
|
||||
creationID := formInt64(r, "creation_id", 0)
|
||||
newServerID := formInt(r, "new_server_id", -1)
|
||||
newProtocol := strings.TrimSpace(r.FormValue("new_protocol"))
|
||||
if !share.LinkServerAllowed(*link, newServerID) {
|
||||
actionErr = errors.New(i18n.T(lang, "err_pick_migrate_server", nil))
|
||||
break
|
||||
}
|
||||
if a.Settings.DisabledServers(ctx)[newServerID] {
|
||||
actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil))
|
||||
break
|
||||
}
|
||||
if !share.ServerProtocolAllowed(ctx, a.Settings, newServerID, newProtocol) {
|
||||
actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil))
|
||||
break
|
||||
}
|
||||
if err := a.Share.TryMigrateConnection(ctx, a.Panel, a.Settings, link, creationID, newServerID, newProtocol); err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
okMsg = i18n.T(lang, "ok_migrated", nil)
|
||||
|
||||
case "renew":
|
||||
code := r.FormValue("code")
|
||||
res, err := a.Share.TryRedeemGuest(ctx, token, code)
|
||||
if err != nil {
|
||||
actionErr = err
|
||||
break
|
||||
}
|
||||
until := ""
|
||||
if res.Link != nil {
|
||||
until = res.Link.ExpiresLabel()
|
||||
}
|
||||
okMsg = i18n.T(lang, "ok_renewed", map[string]string{"days": fmt.Sprint(res.AddDays), "until": until})
|
||||
|
||||
default:
|
||||
actionErr = errors.New("Неизвестное действие.")
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if actionErr != nil {
|
||||
errText = i18n.TranslateGuestError(lang, actionErr.Error())
|
||||
}
|
||||
finishShareAction(a, w, r, token, okMsg, errText)
|
||||
}
|
||||
}
|
||||
|
||||
func finishShareAction(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) {
|
||||
if wantsJSON(r) {
|
||||
if errText != "" {
|
||||
writeJSONError(w, http.StatusBadRequest, errText)
|
||||
return
|
||||
}
|
||||
writeJSONOK(w, map[string]any{"message": okMsg})
|
||||
return
|
||||
}
|
||||
renderSharePage(a, w, r, token, okMsg, errText)
|
||||
}
|
||||
|
||||
// ShareServers returns the guest-visible server catalog for a link as JSON,
|
||||
// used by the page's async refresh (share_servers.php).
|
||||
func ShareServers(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token := r.URL.Query().Get("k")
|
||||
link, err := a.Share.LinkByToken(ctx, token)
|
||||
if err != nil || link == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "Ссылка не найдена.")
|
||||
return
|
||||
}
|
||||
writeJSONOK(w, map[string]any{"servers": guestServersForLink(a, r, link)})
|
||||
}
|
||||
}
|
||||
|
||||
// ShareDownload streams a single downloadable artifact (.conf / .vpn / .zip)
|
||||
// for one of the link's created connections (share_downloads.php).
|
||||
func ShareDownload(a *web.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query()
|
||||
token := q.Get("k")
|
||||
creationID := queryInt(r, "cre", 0)
|
||||
part := q.Get("part")
|
||||
|
||||
link, err := a.Share.LinkByToken(ctx, token)
|
||||
if err != nil || link == nil {
|
||||
http.Error(w, "Ссылка не найдена.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
creations, err := a.Share.ListCreationsForLink(ctx, link.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Ошибка сервера.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
creation := findActiveCreation(creations, int64(creationID))
|
||||
if creation == nil || creation.ResponseJSON == nil {
|
||||
http.Error(w, "Файл не найден.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fp := share.DownloadPayloadForPart(creation.Protocol, creation.ConnectionName, *creation.ResponseJSON, part)
|
||||
if fp == nil {
|
||||
http.Error(w, "Файл не найден.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
serveFilePart(w, fp)
|
||||
}
|
||||
}
|
||||
|
||||
func serveFilePart(w http.ResponseWriter, fp *share.FilePart) {
|
||||
mime := fp.Mime
|
||||
if mime == "" {
|
||||
mime = "text/plain; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(fp.Filename, `"`, "")+`"`)
|
||||
_, _ = w.Write([]byte(fp.Body))
|
||||
}
|
||||
Reference in New Issue
Block a user