Add Go rewrite with Postgres 17 and Dokploy Docker Compose

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-27 08:21:21 +03:00
co-authored by Cursor
parent bac78dd3fc
commit 99a27f00be
67 changed files with 17864 additions and 0 deletions
+40
View File
@@ -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}
}
+114
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
}
+202
View File
@@ -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)
}
}
+78
View File
@@ -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)
}
}
+255
View File
@@ -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)
}
}
+122
View File
@@ -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)
}
}
+252
View File
@@ -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)
})
}
+138
View File
@@ -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)
}
+79
View File
@@ -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})
}
}
+34
View File
@@ -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),
})
}
}
+71
View File
@@ -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")
}
}
+100
View File
@@ -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")
}
}
+65
View File
@@ -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: &note, 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)
}
}
}
+277
View File
@@ -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))
}