Sponsor: prepare for GitHub alternative (#29043)

This commit is contained in:
Michael Geers 2026-04-29 14:49:44 +02:00 • committed by GitHub
parent 3f19b6a6de
commit b9e72b1278
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 766 additions and 133 deletions

View file

@ -8,6 +8,8 @@ option go_package = "proto/pb";
service Auth {
rpc IsAuthorized (AuthRequest) returns (AuthReply) {}
rpc Activate (ActivateRequest) returns (ActivateReply) {}
rpc IsAuthorizedHardware (HardwareRequest) returns (HardwareReply) {}
}
message AuthRequest {
@ -18,4 +20,27 @@ message AuthReply {
bool authorized = 1;
string subject = 2;
google.protobuf.Timestamp expires_at = 3;
string activation_key = 4;
}
message ActivateRequest {
string key = 1;
string email = 2;
string machine_id = 3;
}
message ActivateReply {
string token = 1;
string error = 2;
}
message HardwareRequest {
string machine_id = 1;
string vendor = 2;
map<string, string> metadata = 3;
}
message HardwareReply {
bool authorized = 1;
string subject = 2;
}

View file

@ -1,18 +1,19 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.2
// protoc v7.34.1
// source: proto/auth.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
)
const (
@ -71,6 +72,7 @@ type AuthReply struct {
Authorized bool `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"`
Subject string `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
ActivationKey string `protobuf:"bytes,4,opt,name=activation_key,json=activationKey,proto3" json:"activation_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -126,23 +128,278 @@ func (x *AuthReply) GetExpiresAt() *timestamppb.Timestamp {
return nil
}
func (x *AuthReply) GetActivationKey() string {
if x != nil {
return x.ActivationKey
}
return ""
}
type ActivateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"`
MachineId string `protobuf:"bytes,3,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateRequest) Reset() {
*x = ActivateRequest{}
mi := &file_proto_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateRequest) ProtoMessage() {}
func (x *ActivateRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_auth_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateRequest.ProtoReflect.Descriptor instead.
func (*ActivateRequest) Descriptor() ([]byte, []int) {
return file_proto_auth_proto_rawDescGZIP(), []int{2}
}
func (x *ActivateRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *ActivateRequest) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
func (x *ActivateRequest) GetMachineId() string {
if x != nil {
return x.MachineId
}
return ""
}
type ActivateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateReply) Reset() {
*x = ActivateReply{}
mi := &file_proto_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateReply) ProtoMessage() {}
func (x *ActivateReply) ProtoReflect() protoreflect.Message {
mi := &file_proto_auth_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateReply.ProtoReflect.Descriptor instead.
func (*ActivateReply) Descriptor() ([]byte, []int) {
return file_proto_auth_proto_rawDescGZIP(), []int{3}
}
func (x *ActivateReply) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
func (x *ActivateReply) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type HardwareRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
MachineId string `protobuf:"bytes,1,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
Vendor string `protobuf:"bytes,2,opt,name=vendor,proto3" json:"vendor,omitempty"`
Metadata map[string]string `protobuf:"bytes,3,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HardwareRequest) Reset() {
*x = HardwareRequest{}
mi := &file_proto_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HardwareRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HardwareRequest) ProtoMessage() {}
func (x *HardwareRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_auth_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HardwareRequest.ProtoReflect.Descriptor instead.
func (*HardwareRequest) Descriptor() ([]byte, []int) {
return file_proto_auth_proto_rawDescGZIP(), []int{4}
}
func (x *HardwareRequest) GetMachineId() string {
if x != nil {
return x.MachineId
}
return ""
}
func (x *HardwareRequest) GetVendor() string {
if x != nil {
return x.Vendor
}
return ""
}
func (x *HardwareRequest) GetMetadata() map[string]string {
if x != nil {
return x.Metadata
}
return nil
}
type HardwareReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Authorized bool `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"`
Subject string `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HardwareReply) Reset() {
*x = HardwareReply{}
mi := &file_proto_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HardwareReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HardwareReply) ProtoMessage() {}
func (x *HardwareReply) ProtoReflect() protoreflect.Message {
mi := &file_proto_auth_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HardwareReply.ProtoReflect.Descriptor instead.
func (*HardwareReply) Descriptor() ([]byte, []int) {
return file_proto_auth_proto_rawDescGZIP(), []int{5}
}
func (x *HardwareReply) GetAuthorized() bool {
if x != nil {
return x.Authorized
}
return false
}
func (x *HardwareReply) GetSubject() string {
if x != nil {
return x.Subject
}
return ""
}
var File_proto_auth_proto protoreflect.FileDescriptor
const file_proto_auth_proto_rawDesc = "" +
"\n" +
"\x10proto/auth.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"#\n" +
"\vAuthRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\x80\x01\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\xa7\x01\n" +
"\tAuthReply\x12\x1e\n" +
"\n" +
"authorized\x18\x01 \x01(\bR\n" +
"authorized\x12\x18\n" +
"\asubject\x18\x02 \x01(\tR\asubject\x129\n" +
"\n" +
"expires_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt22\n" +
"expires_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12%\n" +
"\x0eactivation_key\x18\x04 \x01(\tR\ractivationKey\"X\n" +
"\x0fActivateRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05email\x18\x02 \x01(\tR\x05email\x12\x1d\n" +
"\n" +
"machine_id\x18\x03 \x01(\tR\tmachineId\";\n" +
"\rActivateReply\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"\xc1\x01\n" +
"\x0fHardwareRequest\x12\x1d\n" +
"\n" +
"machine_id\x18\x01 \x01(\tR\tmachineId\x12\x16\n" +
"\x06vendor\x18\x02 \x01(\tR\x06vendor\x12:\n" +
"\bmetadata\x18\x03 \x03(\v2\x1e.HardwareRequest.MetadataEntryR\bmetadata\x1a;\n" +
"\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"I\n" +
"\rHardwareReply\x12\x1e\n" +
"\n" +
"authorized\x18\x01 \x01(\bR\n" +
"authorized\x12\x18\n" +
"\asubject\x18\x02 \x01(\tR\asubject2\x9e\x01\n" +
"\x04Auth\x12*\n" +
"\fIsAuthorized\x12\f.AuthRequest\x1a\n" +
".AuthReply\"\x00B\n" +
".AuthReply\"\x00\x12.\n" +
"\bActivate\x12\x10.ActivateRequest\x1a\x0e.ActivateReply\"\x00\x12:\n" +
"\x14IsAuthorizedHardware\x12\x10.HardwareRequest\x1a\x0e.HardwareReply\"\x00B\n" +
"Z\bproto/pbb\x06proto3"
var (
@ -157,21 +414,31 @@ func file_proto_auth_proto_rawDescGZIP() []byte {
return file_proto_auth_proto_rawDescData
}
var file_proto_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_proto_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_proto_auth_proto_goTypes = []any{
(*AuthRequest)(nil), // 0: AuthRequest
(*AuthReply)(nil), // 1: AuthReply
(*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp
(*ActivateRequest)(nil), // 2: ActivateRequest
(*ActivateReply)(nil), // 3: ActivateReply
(*HardwareRequest)(nil), // 4: HardwareRequest
(*HardwareReply)(nil), // 5: HardwareReply
nil, // 6: HardwareRequest.MetadataEntry
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
}
var file_proto_auth_proto_depIdxs = []int32{
2, // 0: AuthReply.expires_at:type_name -> google.protobuf.Timestamp
0, // 1: Auth.IsAuthorized:input_type -> AuthRequest
1, // 2: Auth.IsAuthorized:output_type -> AuthReply
2, // [2:3] is the sub-list for method output_type
1, // [1:2] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
7, // 0: AuthReply.expires_at:type_name -> google.protobuf.Timestamp
6, // 1: HardwareRequest.metadata:type_name -> HardwareRequest.MetadataEntry
0, // 2: Auth.IsAuthorized:input_type -> AuthRequest
2, // 3: Auth.Activate:input_type -> ActivateRequest
4, // 4: Auth.IsAuthorizedHardware:input_type -> HardwareRequest
1, // 5: Auth.IsAuthorized:output_type -> AuthReply
3, // 6: Auth.Activate:output_type -> ActivateReply
5, // 7: Auth.IsAuthorizedHardware:output_type -> HardwareReply
5, // [5:8] is the sub-list for method output_type
2, // [2:5] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_proto_auth_proto_init() }
@ -185,7 +452,7 @@ func file_proto_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_auth_proto_rawDesc), len(file_proto_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumMessages: 7,
NumExtensions: 0,
NumServices: 1,
},

View file

@ -1,13 +1,14 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.2
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: proto/auth.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
@ -19,7 +20,9 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
Auth_IsAuthorized_FullMethodName = "/Auth/IsAuthorized"
Auth_IsAuthorized_FullMethodName = "/Auth/IsAuthorized"
Auth_Activate_FullMethodName = "/Auth/Activate"
Auth_IsAuthorizedHardware_FullMethodName = "/Auth/IsAuthorizedHardware"
)
// AuthClient is the client API for Auth service.
@ -27,6 +30,8 @@ const (
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AuthClient interface {
IsAuthorized(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthReply, error)
Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateReply, error)
IsAuthorizedHardware(ctx context.Context, in *HardwareRequest, opts ...grpc.CallOption) (*HardwareReply, error)
}
type authClient struct {
@ -47,11 +52,33 @@ func (c *authClient) IsAuthorized(ctx context.Context, in *AuthRequest, opts ...
return out, nil
}
func (c *authClient) Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ActivateReply)
err := c.cc.Invoke(ctx, Auth_Activate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authClient) IsAuthorizedHardware(ctx context.Context, in *HardwareRequest, opts ...grpc.CallOption) (*HardwareReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HardwareReply)
err := c.cc.Invoke(ctx, Auth_IsAuthorizedHardware_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServer is the server API for Auth service.
// All implementations must embed UnimplementedAuthServer
// for forward compatibility.
type AuthServer interface {
IsAuthorized(context.Context, *AuthRequest) (*AuthReply, error)
Activate(context.Context, *ActivateRequest) (*ActivateReply, error)
IsAuthorizedHardware(context.Context, *HardwareRequest) (*HardwareReply, error)
mustEmbedUnimplementedAuthServer()
}
@ -65,6 +92,12 @@ type UnimplementedAuthServer struct{}
func (UnimplementedAuthServer) IsAuthorized(context.Context, *AuthRequest) (*AuthReply, error) {
return nil, status.Error(codes.Unimplemented, "method IsAuthorized not implemented")
}
func (UnimplementedAuthServer) Activate(context.Context, *ActivateRequest) (*ActivateReply, error) {
return nil, status.Error(codes.Unimplemented, "method Activate not implemented")
}
func (UnimplementedAuthServer) IsAuthorizedHardware(context.Context, *HardwareRequest) (*HardwareReply, error) {
return nil, status.Error(codes.Unimplemented, "method IsAuthorizedHardware not implemented")
}
func (UnimplementedAuthServer) mustEmbedUnimplementedAuthServer() {}
func (UnimplementedAuthServer) testEmbeddedByValue() {}
@ -104,6 +137,42 @@ func _Auth_IsAuthorized_Handler(srv interface{}, ctx context.Context, dec func(i
return interceptor(ctx, in, info, handler)
}
func _Auth_Activate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ActivateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServer).Activate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Auth_Activate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServer).Activate(ctx, req.(*ActivateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Auth_IsAuthorizedHardware_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HardwareRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServer).IsAuthorizedHardware(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Auth_IsAuthorizedHardware_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServer).IsAuthorizedHardware(ctx, req.(*HardwareRequest))
}
return interceptor(ctx, in, info, handler)
}
// Auth_ServiceDesc is the grpc.ServiceDesc for Auth service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -115,6 +184,14 @@ var Auth_ServiceDesc = grpc.ServiceDesc{
MethodName: "IsAuthorized",
Handler: _Auth_IsAuthorized_Handler,
},
{
MethodName: "Activate",
Handler: _Auth_Activate_Handler,
},
{
MethodName: "IsAuthorizedHardware",
Handler: _Auth_IsAuthorizedHardware_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/auth.proto",

View file

@ -11,7 +11,10 @@
:disable-remove="!hasUiToken"
disable-cancel
@changed="$emit('changed')"
@open="showForm = false"
@open="
showForm = false;
activeTab = 'github';
"
>
<template #default="{ values }">
<div class="mt-4 mb-3">
@ -19,67 +22,177 @@
</div>
<hr class="my-4" />
<div v-if="showTokenForm">
<div class="d-flex justify-content-between align-items-center">
<label for="sponsorToken" class="fw-bold my-2">{{
$t("config.sponsor.enterYourToken")
<!-- hidden feature: tab navigation
<ul v-if="$hiddenFeatures()" class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link"
:class="{ active: activeTab === 'github' }"
type="button"
@click="
if (activeTab !== 'github') {
values.token = '';
values.email = '';
}
activeTab = 'github';
"
>
Sponsor Token
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
:class="{ active: activeTab === 'direct' }"
type="button"
@click="
if (activeTab !== 'direct') {
values.token = '';
values.email = '';
}
activeTab = 'direct';
"
>
Activation Key 🧪
</button>
</li>
</ul>
-->
<div v-if="activeTab === 'github'">
<label for="sponsorToken" class="my-2">
{{ $t("config.sponsor.enterYourToken") }}
</label>
<textarea
id="sponsorToken"
v-model.trim="values.token"
class="form-control mb-1"
required
rows="5"
spellcheck="false"
@paste="(event) => handlePaste(event, values)"
/>
<i18n-t tag="small" keypath="config.sponsor.descriptionToken" scope="global">
<template #url>
<a href="https://sponsor.evcc.io" target="_blank">sponsor.evcc.io</a>
</template>
<template #trialToken>
<a :href="trialTokenLink" target="_blank">{{
$t("config.sponsor.trialToken")
}}</a>
</template>
</i18n-t>
</div>
<div v-else-if="activeTab === 'direct'">
<label for="sponsorEmail" class="my-2">{{ $t("config.sponsor.email") }}</label>
<input
id="sponsorEmail"
v-model.trim="values.email"
type="email"
class="form-control mb-1"
required
/>
<i18n-t
tag="small"
keypath="config.sponsor.emailHint"
scope="global"
class="mb-3 d-block text-muted"
>
<template #url>
<a href="https://sponsor.evcc.io/" target="_blank">direct sponsoring</a>
</template>
</i18n-t>
<label for="licenseKey" class="my-2">{{
$t("config.sponsor.activationKey")
}}</label>
<input
id="licenseKey"
v-model.trim="values.token"
class="form-control mb-1 font-monospace"
required
spellcheck="false"
pattern="[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}"
title="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
@input="values.token = values.token.toUpperCase()"
/>
<i18n-t tag="small" keypath="config.sponsor.activationKeyHint" scope="global">
<template #url>
<a href="about:blank" target="_blank">Customer Portal</a>
</template>
</i18n-t>
</div>
<div v-if="hasUiToken" class="d-flex justify-content-end mt-3">
<button
v-if="hasUiToken"
type="button"
class="btn btn-link btn-sm text-nowrap text-muted"
class="btn btn-link text-nowrap text-muted"
@click="
editMode = false;
values.token = '';
values.email = '';
"
>
{{ $t("config.general.cancel") }}
</button>
</div>
<textarea
id="sponsorToken"
v-model="values.token"
class="form-control mb-1"
required
rows="5"
spellcheck="false"
@paste="(event) => handlePaste(event, values)"
/>
<i18n-t tag="small" keypath="config.sponsor.descriptionToken" scope="global">
<template #url>
<a href="https://sponsor.evcc.io" target="_blank">sponsor.evcc.io</a>
</template>
<template #trialToken>
<a :href="trialTokenLink" target="_blank">{{
$t("config.sponsor.trialToken")
}}</a>
</template>
</i18n-t>
</div>
<div v-else-if="token">
<label for="existingToken" class="fw-bold my-2">{{
$t("config.sponsor.yourToken")
}}</label>
<div class="d-flex align-items-start gap-2 text-muted">
<input
id="existingToken"
:value="token"
disabled
rows="1"
class="form-control"
:class="{ 'is-invalid': error }"
/>
<span v-if="fromYaml" class="text-muted text-nowrap align-self-center ms-2">
{{ $t("config.sponsor.viaYaml") }}
</span>
<button
v-else
type="button"
class="btn btn-link text-nowrap"
:class="error ? 'text-danger' : 'text-muted'"
@click="editMode = true"
>
{{ $t("config.sponsor.changeToken") }}
</button>
<div v-if="activationKey">
<label for="existingEmail" class="fw-bold my-2">{{
$t("config.sponsor.email")
}}</label>
<div class="text-muted mb-3">
<input id="existingEmail" :value="name" disabled class="form-control" />
</div>
<label for="existingActivationKey" class="fw-bold my-2">{{
$t("config.sponsor.activationKey")
}}</label>
<div class="text-muted">
<input
id="existingActivationKey"
:value="activationKey"
disabled
class="form-control font-monospace"
:class="{ 'is-invalid': error }"
/>
</div>
<div class="d-flex justify-content-end mt-2">
<button
type="button"
class="btn btn-link text-nowrap"
:class="error ? 'text-danger' : 'text-muted'"
@click="editMode = true"
>
{{ $t("config.general.change") }}
</button>
</div>
</div>
<div v-else>
<label for="existingToken" class="fw-bold my-2">{{
$t("config.sponsor.yourToken")
}}</label>
<div class="text-muted">
<input
id="existingToken"
:value="token"
disabled
rows="1"
class="form-control"
:class="{ 'is-invalid': error }"
/>
</div>
<div class="d-flex justify-content-end mt-2">
<span v-if="fromYaml" class="text-nowrap small text-muted">
{{ $t("config.sponsor.viaYaml") }}
</span>
<button
v-else
type="button"
class="btn btn-link text-nowrap"
:class="error ? 'text-danger' : 'text-muted'"
@click="editMode = true"
>
{{ $t("config.sponsor.changeToken") }}
</button>
</div>
</div>
<SponsorTokenExpires v-bind="sponsor" />
</div>
@ -103,6 +216,7 @@ export default {
emits: ["changed"],
data: () => ({
editMode: false,
activeTab: "github",
}),
computed: {
sponsor() {
@ -111,6 +225,9 @@ export default {
token() {
return this.sponsor?.status?.token;
},
activationKey() {
return this.sponsor?.status?.activationKey;
},
fromYaml() {
return !!this.sponsor?.yamlSource;
},
@ -132,13 +249,21 @@ export default {
},
methods: {
transformReadValues() {
return { token: "" };
return { token: "", email: "" };
},
handlePaste(event, values) {
event.preventDefault();
const text = event.clipboardData.getData("text");
const cleaned = cleanYaml(text, "sponsortoken");
values.token = cleaned;
if (this.activeTab === "github" && this.isLicenseKey(cleaned)) {
this.activeTab = "direct";
}
},
isLicenseKey(token) {
// Match pattern XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (case-insensitive alphanumeric)
const pattern = /^[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/i;
return pattern.test(token || "");
},
},
};

View file

@ -66,7 +66,6 @@ import confetti from "canvas-confetti";
import "@h2d2/shopicons/es/regular/heart";
import "@h2d2/shopicons/es/regular/stars";
import "@h2d2/shopicons/es/regular/clock";
import { docsPrefix } from "@/i18n";
import { defineComponent, type PropType } from "vue";
import type { SponsorStatus } from "@/types/evcc";
@ -92,7 +91,7 @@ export default defineComponent({
return this.name && !this.isTrial && !this.isVictronDevice;
},
sponsorLink() {
return `${docsPrefix()}/docs/sponsorship`;
return "https://sponsor.evcc.io";
},
},
methods: {

View file

@ -228,6 +228,7 @@
"authPerformHint": "Öffnet ein neues Tab. Anschließend hier weiter machen.",
"authPrepare": "Verbindung vorbereiten",
"cancel": "Abbrechen",
"change": "Ändern",
"clear": "Löschen",
"close": "Schließen",
"confirmSave": "Es gibt ungespeicherte Änderungen. Jetzt speichern?",
@ -693,11 +694,15 @@
"title": "SMA Sunny Home Manager"
},
"sponsor": {
"activationKey": "Lizenzschlüssel",
"activationKeyHint": "Per E-Mail zugesandt. Kann im {url} gefunden werden.",
"addToken": "Token eingeben",
"changeToken": "Token ändern",
"description": "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen.",
"descriptionToken": "Als Sponsor erhältst du dein Token auf {url}. Zum Ausprobieren gibts ein {trialToken}.",
"enterYourToken": "Token eingeben",
"descriptionToken": "Als GitHub-Sponsor findest du dein Token auf {url}. Zum Ausprobieren gibt's ein {trialToken}.",
"email": "E-Mail",
"emailHint": "E-Mail-Adresse, die du für {url} verwendet hast",
"enterYourToken": "Dein Sponsor Token",
"error": "Das Sponsortoken ist ungültig.",
"invalid": "ungültig",
"labelToken": "Sponsortoken",
@ -708,7 +713,7 @@
"tokenRequiredShort": "Kein Sponsortoken konfiguriert.",
"trialToken": "Testtoken",
"viaYaml": "über evcc.yaml",
"yourToken": "Dein Token"
"yourToken": "Sponsor Token"
},
"system": {
"backupRestore": {

View file

@ -228,6 +228,7 @@
"authPerformHint": "Will open in a new tab. Return here to continue.",
"authPrepare": "Prepare connection",
"cancel": "Cancel",
"change": "Change",
"clear": "Clear",
"close": "Close",
"confirmSave": "There are unsaved changes. Save now?",
@ -693,11 +694,15 @@
"title": "SMA Sunny Home Manager"
},
"sponsor": {
"activationKey": "License Key",
"activationKeyHint": "Sent to you via email. Can be found in the {url}.",
"addToken": "Enter token",
"changeToken": "Change token",
"description": "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations.",
"descriptionToken": "Sponsors find their token on {url}. For getting-started, we offer a {trialToken}.",
"enterYourToken": "Enter your token",
"descriptionToken": "As GitHub Sponsor you find your token on {url}. For getting-started, we offer a {trialToken}.",
"email": "Email",
"emailHint": "Email address you used for {url}",
"enterYourToken": "Your Sponsor Token",
"error": "The sponsor token is not valid.",
"invalid": "invalid",
"labelToken": "Sponsor token",
@ -708,7 +713,7 @@
"tokenRequiredShort": "No sponsor token configured.",
"trialToken": "trial token",
"viaYaml": "via evcc.yaml",
"yourToken": "Your token"
"yourToken": "Sponsor Token"
},
"system": {
"backupRestore": {

View file

@ -2,7 +2,10 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core/keys"
@ -10,6 +13,8 @@ import (
"github.com/evcc-io/evcc/util/sponsor"
)
var licenseKeyPattern = regexp.MustCompile(`^[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$`)
func setOptimizer(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Optimizer, b)
@ -40,6 +45,7 @@ func updateSponsortokenHandler(pub publisher) func(w http.ResponseWriter, r *htt
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -47,8 +53,30 @@ func updateSponsortokenHandler(pub publisher) func(w http.ResponseWriter, r *htt
return
}
if req.Token != "" {
if err := sponsor.ConfigureSponsorship(req.Token); err != nil {
var token string
// License key activation flow
if req.Email != "" {
// Validate token matches license key pattern
if !licenseKeyPattern.MatchString(strings.ToUpper(req.Token)) {
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid license key format"))
return
}
// Activate license key and receive JWT token
var err error
token, err = sponsor.ActivateSponsorship(req.Token, req.Email)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
} else {
// Use provided JWT token directly
token = req.Token
}
if token != "" {
if err := sponsor.ConfigureSponsorship(token); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@ -60,7 +88,7 @@ func updateSponsortokenHandler(pub publisher) func(w http.ResponseWriter, r *htt
}
// TODO find better place
settings.SetString(keys.SponsorToken, req.Token)
settings.SetString(keys.SponsorToken, token)
setConfigDirty()
jsonWrite(w, sponsor.RedactedStatus())

View file

@ -27,7 +27,7 @@ test.describe("sponsor token", () => {
await sponsorEntry.getByRole("button", { name: "Edit" }).click();
const modal = page.getByTestId("sponsor-modal");
const tokenInput = modal.getByRole("textbox", { name: "Your token" });
const tokenInput = modal.getByRole("textbox", { name: "Sponsor Token" });
await expect(tokenInput).toHaveValue(SHORT_TOKEN);
await expect(tokenInput).toHaveClass(/is-invalid/);
@ -51,7 +51,7 @@ test.describe("sponsor token", () => {
// Click change button to reveal textarea
await modal.getByRole("button", { name: "Change token" }).click();
await expect(modal.getByRole("textbox", { name: "Enter your token" })).toBeVisible();
await expect(modal.getByRole("textbox", { name: "Your Sponsor Token" })).toBeVisible();
await expect(modal.getByRole("button", { name: "Remove" })).toBeVisible();
});
@ -66,7 +66,7 @@ test.describe("sponsor token", () => {
.click();
const modal = page.getByTestId("sponsor-modal");
const textarea = modal.getByRole("textbox", { name: "Enter your token" });
const textarea = modal.getByRole("textbox", { name: "Your Sponsor Token" });
await textarea.fill(EXPIRED_TOKEN);
// Try to save to trigger validation

View file

@ -27,14 +27,16 @@ import (
"github.com/evcc-io/evcc/api/proto/pb"
"github.com/evcc-io/evcc/util/cloud"
"github.com/evcc-io/evcc/util/machine"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
mu sync.RWMutex
Subject, Token string
ExpiresAt time.Time
mu sync.RWMutex
Subject, Token, ActivationKey string
ExpiresAt time.Time
machineID = machine.ProtectedID("evcc-sponsor")
)
const unavailable = "sponsorship unavailable"
@ -51,6 +53,35 @@ func IsAuthorizedForApi() bool {
return IsAuthorized() && Subject != unavailable && Token != ""
}
// ActivateSponsorship activates a license key with email and returns the JWT token
func ActivateSponsorship(licenseKey, email string) (string, error) {
conn, err := cloud.Connection()
if err != nil {
return "", fmt.Errorf("connection failed: %w", err)
}
client := pb.NewAuthClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := client.Activate(ctx, &pb.ActivateRequest{
Key: licenseKey,
Email: email,
MachineId: machineID,
})
if err != nil {
return "", fmt.Errorf("activation failed: %w", err)
}
if res.Error != "" {
return "", fmt.Errorf("%s", res.Error)
}
return res.Token, nil
}
// check and set sponsorship token
func ConfigureSponsorship(token string) error {
mu.Lock()
@ -90,6 +121,7 @@ func ConfigureSponsorship(token string) error {
res, err := client.IsAuthorized(ctx, &pb.AuthRequest{Token: token})
if err == nil && res.Authorized {
Subject = res.Subject
ActivationKey = res.ActivationKey
ExpiresAt = res.ExpiresAt.AsTime()
}
@ -117,11 +149,20 @@ func redactToken(token string) string {
return token[:6] + "......." + token[len(token)-6:]
}
// redactKey returns a redacted version of the activation key showing only the first segment
func redactKey(key string) string {
if idx := strings.Index(key, "-"); idx > 0 {
return key[:idx] + "-XXXXX-XXXXX-XXXXX-XXXXX"
}
return ""
}
type Status struct {
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
ExpiresSoon bool `json:"expiresSoon,omitempty"`
Token string `json:"token,omitempty"`
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
ExpiresSoon bool `json:"expiresSoon,omitempty"`
Token string `json:"token,omitempty"`
ActivationKey string `json:"activationKey,omitempty"`
}
// RedactedStatus returns the sponsorship status
@ -135,9 +176,10 @@ func RedactedStatus() Status {
}
return Status{
Name: Subject,
ExpiresAt: ExpiresAt,
ExpiresSoon: expiresSoon,
Token: redactToken(Token),
Name: Subject,
ExpiresAt: ExpiresAt,
ExpiresSoon: expiresSoon,
Token: redactToken(Token),
ActivationKey: redactKey(ActivationKey),
}
}

56
util/sponsor/hardware.go Normal file
View file

@ -0,0 +1,56 @@
package sponsor
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
"context"
"github.com/evcc-io/evcc/api/proto/pb"
"github.com/evcc-io/evcc/util/cloud"
"github.com/evcc-io/evcc/util/request"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// checkHardware registers the device with the sponsor server and checks authorization.
func checkHardware(vendor string, metadata map[string]string) string {
conn, err := cloud.Connection()
if err != nil {
return unavailable
}
ctx, cancel := context.WithTimeout(context.Background(), request.Timeout)
defer cancel()
client := pb.NewAuthClient(conn)
res, err := client.IsAuthorizedHardware(ctx, &pb.HardwareRequest{
MachineId: machineID,
Vendor: vendor,
Metadata: metadata,
})
if err == nil && res.Authorized {
return res.Subject
}
if s, ok := status.FromError(err); ok && s.Code() != codes.Unknown {
return unavailable
}
return ""
}

View file

@ -2,6 +2,23 @@
package sponsor
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// checkHemsPro checks if the hardware is a supported HEMS Pro device and returns sponsor subject
func checkHemsPro() string {
return ""

View file

@ -2,6 +2,23 @@
package sponsor
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
i2c "github.com/d2r2/go-i2c"
)
@ -31,5 +48,6 @@ func checkHemsPro() string {
return ""
}
return hemspro
// I2C succeeded — verify with server
return checkHardware(hemspro, nil)
}

View file

@ -24,52 +24,21 @@ import (
"runtime"
"strings"
"time"
"github.com/evcc-io/evcc/api/proto/pb"
"github.com/evcc-io/evcc/util/cloud"
"github.com/evcc-io/evcc/util/request"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const victron = "victron"
// checkVictron checks if the hardware is a supported victron device and returns sponsor subject
func checkVictron() string {
vd, err := victronDeviceInfo()
if err != nil {
// unable to retrieve all device info
return ""
}
conn, err := cloud.Connection()
if err != nil {
// unable to connect to cloud
return unavailable
}
ctx, cancel := context.WithTimeout(context.Background(), request.Timeout)
defer cancel()
client := pb.NewVictronClient(conn)
res, err := client.IsValidDevice(ctx, &pb.VictronRequest{
ProductId: vd.ProductId,
VrmId: vd.VrmId,
Serial: vd.Serial,
Board: vd.Board,
return checkHardware("victron", map[string]string{
"board": vd.Board,
"productId": vd.ProductId,
"vrmId": vd.VrmId,
"serial": vd.Serial,
})
if err == nil && res.Authorized {
// cloud check successful
return victron
}
if s, ok := status.FromError(err); ok && s.Code() != codes.Unknown {
// technical error during validation
return unavailable
}
return ""
}
type victronDevice struct {