mirror of
https://wiilab.wiimart.org/wiimart/WiiSOAP
synced 2025-09-03 20:11:14 +02:00
424 lines
12 KiB
Go
424 lines
12 KiB
Go
// Copyright (C) 2018-2020 CornierKhan1
|
|
//
|
|
// WiiSOAP is SOAP Server Software, designed specifically to handle Wii Shop Channel SOAP.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see http://www.gnu.org/licenses/.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
v1Ticket "github.com/OpenShopChannel/V1TicketGenerator"
|
|
"github.com/wii-tools/wadlib"
|
|
"log"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
QueryOwnedTitles = `SELECT owned_titles.title_id
|
|
FROM owned_titles
|
|
WHERE owned_titles.account_id = $1`
|
|
|
|
QueryOwnedServiceTitles = `SELECT titles.reference_id, owned_titles.date_purchased, titles.item_id
|
|
FROM titles, owned_titles
|
|
WHERE titles.item_id = owned_titles.item_id
|
|
AND titles.title_id = $1
|
|
AND owned_titles.account_id = $2`
|
|
|
|
AssociateTicketStatement = `INSERT INTO owned_titles (account_id, title_id, version, item_id, date_purchased)
|
|
VALUES ($1, $2, $3, $4, $5)`
|
|
|
|
// SharedBalanceAmount describes the maximum signed 32-bit integer value.
|
|
// It is not an actual tracked points value, but exists to permit reuse.
|
|
SharedBalanceAmount = math.MaxInt32
|
|
|
|
// WiinoMaApplicationID is the title ID for the Japanese channel Wii no Ma.
|
|
WiinoMaApplicationID = "000100014843494A"
|
|
// WiinoMaServiceTitleID is the service ID used by Wii no Ma's theatre.
|
|
WiinoMaServiceTitleID = "000101006843494A"
|
|
)
|
|
|
|
// contentAesKey is the AES key that is used to encrypt title contents.
|
|
var contentAesKey = [16]byte{0x72, 0x95, 0xDB, 0xC0, 0x47, 0x3C, 0x90, 0x0B, 0xB5, 0x94, 0x19, 0x9C, 0xB5, 0xBC, 0xD3, 0xDC}
|
|
|
|
func getBalance() Balance {
|
|
return Balance{
|
|
Amount: SharedBalanceAmount,
|
|
Currency: "POINTS",
|
|
}
|
|
}
|
|
|
|
func checkDeviceStatus(e *Envelope) {
|
|
e.AddCustomType(getBalance())
|
|
e.AddKVNode("ForceSyncTime", "0")
|
|
e.AddKVNode("ExtTicketTime", e.Timestamp())
|
|
e.AddKVNode("SyncTime", e.Timestamp())
|
|
}
|
|
|
|
func notifyETicketsSynced(e *Envelope) {
|
|
// TODO: Implement handling of synchronization timing
|
|
}
|
|
|
|
func listETickets(e *Envelope) {
|
|
accountId, err := e.AccountId()
|
|
if err != nil {
|
|
e.Error(2, "missing account ID", err)
|
|
return
|
|
}
|
|
|
|
rows, err := pool.Query(ctx, QueryOwnedTitles, accountId)
|
|
if err != nil {
|
|
log.Printf("error executing statement: %v\n", err)
|
|
e.Error(2, "database error", errors.New("failed to execute db operation"))
|
|
return
|
|
}
|
|
|
|
// Add all available titles for this account.
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var titleId string
|
|
err = rows.Scan(&titleId)
|
|
if err != nil {
|
|
log.Printf("error executing statement: %v\n", err)
|
|
e.Error(2, "database error", errors.New("failed to execute db operation"))
|
|
return
|
|
}
|
|
|
|
app, err := GetOSCApp(titleId)
|
|
if err != nil {
|
|
e.Error(2, "an error has occurred retrieving app metadata", err)
|
|
return
|
|
}
|
|
|
|
if app == nil {
|
|
// Quite possibly an app was de-listed?
|
|
e.Error(2, "title does not exist", nil)
|
|
return
|
|
}
|
|
|
|
e.AddCustomType(Tickets{
|
|
TitleId: titleId,
|
|
Version: app.Shop.Version,
|
|
|
|
// We do not support migration, ticket IDs, or revocation.
|
|
TicketId: "0",
|
|
RevokeDate: 0,
|
|
MigrateCount: 0,
|
|
MigrateLimit: 0,
|
|
})
|
|
}
|
|
|
|
e.AddKVNode("ForceSyncTime", "0")
|
|
e.AddKVNode("ExtTicketTime", e.Timestamp())
|
|
e.AddKVNode("SyncTime", e.Timestamp())
|
|
}
|
|
|
|
func getETickets(e *Envelope) {
|
|
e.AddKVNode("ForceSyncTime", "0")
|
|
e.AddKVNode("ExtTicketTime", e.Timestamp())
|
|
e.AddKVNode("SyncTime", e.Timestamp())
|
|
}
|
|
|
|
func purchaseTitle(e *Envelope) {
|
|
accountId, err := e.AccountId()
|
|
if err != nil {
|
|
e.Error(2, "missing account ID", err)
|
|
return
|
|
}
|
|
|
|
tempItemId, err := e.getKey("ItemId")
|
|
if err != nil {
|
|
e.Error(2, "missing item ID", err)
|
|
return
|
|
}
|
|
|
|
// Our struct takes an integer rather than a string.
|
|
itemId, _ := strconv.Atoi(tempItemId)
|
|
|
|
// Determine the title ID we're going to purchase.
|
|
titleId, err := e.getKey("TitleId")
|
|
if err != nil {
|
|
e.Error(2, "missing account ID", err)
|
|
return
|
|
}
|
|
|
|
ticket := new(bytes.Buffer)
|
|
var ticketStruct wadlib.Ticket
|
|
err = binary.Read(bytes.NewReader(wadlib.TicketTemplate), binary.BigEndian, &ticketStruct)
|
|
if err != nil {
|
|
// Should never happen but report
|
|
e.Error(2, "error reading ticket template", err)
|
|
return
|
|
}
|
|
|
|
// We will now formulate the ticket for this title.
|
|
intTitleId, err := strconv.ParseUint(titleId, 16, 64)
|
|
if err != nil {
|
|
e.Error(2, "invalid title id", err)
|
|
return
|
|
}
|
|
|
|
ticketStruct.TitleID = intTitleId
|
|
|
|
// Title key is encrypted with the common key and current title ID
|
|
ticketStruct.UpdateTitleKey(contentAesKey)
|
|
|
|
version := 0
|
|
if titleId == WiinoMaServiceTitleID {
|
|
// Wii no Ma needs the ticket to be in the v1 ticket format.
|
|
// Update the ticket to reflect that.
|
|
ticketStruct.FileVersion = 1
|
|
ticketStruct.AccessTitleMask = math.MaxUint32
|
|
ticketStruct.LicenseType = 5
|
|
|
|
err = binary.Write(ticket, binary.BigEndian, ticketStruct)
|
|
if err != nil {
|
|
e.Error(2, "failed to create ticket", err)
|
|
return
|
|
}
|
|
|
|
refId, err := e.getKey("ReferenceId")
|
|
if err != nil {
|
|
e.Error(2, "missing reference ID", err)
|
|
return
|
|
}
|
|
|
|
// Convert reference ID to bytes
|
|
refIdBytes, err := hex.DecodeString(refId)
|
|
if err != nil {
|
|
log.Printf("unexpected error converting reference id to bytes: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
var referenceId [16]byte
|
|
copy(referenceId[:], refIdBytes)
|
|
|
|
subscriptions := []v1Ticket.V1SubscriptionRecord{
|
|
{
|
|
ExpirationTime: uint32(time.Now().AddDate(0, 1, 0).Unix()),
|
|
ReferenceID: referenceId,
|
|
},
|
|
}
|
|
|
|
// Query the database for other purchased items of the same title id.
|
|
rows, err := pool.Query(ctx, QueryOwnedServiceTitles, titleId, accountId)
|
|
if err != nil {
|
|
log.Printf("unexpected error purchasing: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var currentRefIdString string
|
|
var purchasedTime time.Time
|
|
err = rows.Scan(¤tRefIdString, &purchasedTime, nil)
|
|
if err != nil {
|
|
log.Printf("unexpected error purchasing: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
refIdBytes, err = hex.DecodeString(currentRefIdString)
|
|
if err != nil {
|
|
log.Printf("unexpected error converting reference id to bytes: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
var currentReferenceId [16]byte
|
|
copy(currentReferenceId[:], refIdBytes)
|
|
subscriptions = append(subscriptions, v1Ticket.V1SubscriptionRecord{
|
|
ExpirationTime: uint32(purchasedTime.AddDate(0, 0, 30).Unix()),
|
|
ReferenceID: currentReferenceId,
|
|
})
|
|
}
|
|
|
|
newTicket, err := v1Ticket.CreateV1Ticket(ticket.Bytes(), subscriptions)
|
|
if err != nil {
|
|
log.Printf("unexpected error creating v1Ticket: %v", err)
|
|
e.Error(2, "error creating ticket", nil)
|
|
return
|
|
}
|
|
|
|
ticket = bytes.NewBuffer(newTicket)
|
|
} else {
|
|
// Validate that this title exists.
|
|
app, err := GetOSCApp(titleId)
|
|
if err != nil {
|
|
e.Error(2, "an error has occurred retrieving app metadata", err)
|
|
return
|
|
}
|
|
|
|
if app == nil {
|
|
e.Error(2, "title does not exist", nil)
|
|
return
|
|
}
|
|
|
|
version = app.Shop.Version
|
|
|
|
err = binary.Write(ticket, binary.BigEndian, ticketStruct)
|
|
if err != nil {
|
|
e.Error(2, "failed to create ticket", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Associate the given title ID with the user.
|
|
_, err = pool.Exec(ctx, AssociateTicketStatement, accountId, titleId, version, itemId, time.Now().UTC())
|
|
if err != nil {
|
|
log.Printf("unexpected error purchasing: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
}
|
|
|
|
// The returned ticket is expected to have two other certificates associated.
|
|
ticketString := b64(append(ticket.Bytes(), wadlib.CertChainTemplate...))
|
|
|
|
e.AddCustomType(getBalance())
|
|
e.AddCustomType(Transactions{
|
|
TransactionId: "00000000",
|
|
Date: e.Timestamp(),
|
|
Type: "PURCHGAME",
|
|
TotalPaid: 0,
|
|
Currency: "POINTS",
|
|
ItemId: itemId,
|
|
ItemPricing: Prices{
|
|
ItemId: itemId,
|
|
Price: Price{Amount: 0, Currency: "POINTS"},
|
|
Limits: LimitStruct(PR),
|
|
LicenseKind: PERMANENT,
|
|
},
|
|
})
|
|
e.AddKVNode("SyncTime", e.Timestamp())
|
|
|
|
e.AddKVNode("ETickets", ticketString)
|
|
// Two cert types must be present.
|
|
e.AddKVNode("Certs", b64(wadlib.CertChainTemplate))
|
|
e.AddKVNode("Certs", b64(wadlib.CertChainTemplate))
|
|
e.AddKVNode("TitleId", titleId)
|
|
}
|
|
|
|
func listPurchaseHistory(e *Envelope) {
|
|
accountId, err := e.AccountId()
|
|
if err != nil {
|
|
e.Error(2, "missing account ID", err)
|
|
return
|
|
}
|
|
|
|
titleId, err := e.getKey("ApplicationId")
|
|
if err != nil {
|
|
e.Error(2, "missing application ID", err)
|
|
return
|
|
}
|
|
|
|
var transactions []Transactions
|
|
if titleId == WiinoMaApplicationID {
|
|
// We will query the database differently for Wii no Ma.
|
|
rows, err := pool.Query(ctx, QueryOwnedServiceTitles, WiinoMaServiceTitleID, accountId)
|
|
if err != nil {
|
|
log.Printf("unexpected error querying owned service titles: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var refId string
|
|
var purchasedTime time.Time
|
|
var itemId int
|
|
err = rows.Scan(&refId, &purchasedTime, &itemId)
|
|
if err != nil {
|
|
log.Printf("unexpected error purchasing: %v", err)
|
|
e.Error(2, "error purchasing", nil)
|
|
return
|
|
}
|
|
|
|
transaction := Transactions{
|
|
TransactionId: "00000000",
|
|
// (Sketch) I don't know why but Wii no Ma won't acknowledge the entry if it isn't past a day from
|
|
// purchase.
|
|
Date: strconv.Itoa(int(purchasedTime.AddDate(0, 0, -1).UnixMilli())),
|
|
Type: "PURCHGAME",
|
|
TotalPaid: 0,
|
|
Currency: "POINTS",
|
|
ItemId: itemId,
|
|
ItemPricing: Prices{
|
|
ItemId: itemId,
|
|
Price: Price{
|
|
Amount: 0,
|
|
Currency: "POINTS",
|
|
},
|
|
Limits: LimitStruct(PR),
|
|
LicenseKind: SERVICE,
|
|
},
|
|
TitleId: WiinoMaServiceTitleID,
|
|
ItemCode: itemId,
|
|
ReferenceId: refId,
|
|
}
|
|
|
|
transactions = append(transactions, transaction)
|
|
}
|
|
} else {
|
|
transactions = append(transactions, Transactions{
|
|
TransactionId: "00000000",
|
|
// Is timestamp in milliseconds, placeholder one is Wed Oct 19 2022 18:02:46
|
|
Date: "1666202566218",
|
|
Type: "PURCHGAME",
|
|
TotalPaid: 0,
|
|
Currency: "POINTS",
|
|
ItemId: 0,
|
|
ItemPricing: Prices{
|
|
ItemId: 0,
|
|
Price: Price{
|
|
Amount: 0,
|
|
Currency: "POINTS",
|
|
},
|
|
Limits: LimitStruct(PR),
|
|
LicenseKind: PERMANENT,
|
|
},
|
|
TitleId: "000101006843494A",
|
|
})
|
|
}
|
|
|
|
e.AddCustomType(transactions)
|
|
e.AddKVNode("ListResultTotalSize", strconv.Itoa(len(transactions)))
|
|
}
|
|
|
|
// genServiceUrl returns a URL with the given service against a configured URL.
|
|
// Given a baseUrl of example.com and genServiceUrl("ias", "IdentityAuthenticationSOAP"),
|
|
// it would return http://ias.example.com/ias/services/ias/IdentityAuthenticationSOAP.
|
|
func genServiceUrl(service string, path string) string {
|
|
return fmt.Sprintf("http://%s.%s/%s/services/%s", service, baseUrl, service, path)
|
|
}
|
|
|
|
func getECConfig(e *Envelope) {
|
|
contentUrl := fmt.Sprintf("http://ccs.%s/ccs/download", baseUrl)
|
|
e.AddKVNode("ContentPrefixURL", contentUrl)
|
|
e.AddKVNode("UncachedContentPrefixURL", contentUrl)
|
|
e.AddKVNode("SystemContentPrefixURL", contentUrl)
|
|
e.AddKVNode("SystemUncachedContentPrefixURL", contentUrl)
|
|
|
|
e.AddKVNode("EcsURL", genServiceUrl("ecs", "ECommerceSOAP"))
|
|
e.AddKVNode("IasURL", genServiceUrl("ias", "IdentityAuthenticationSOAP"))
|
|
e.AddKVNode("CasURL", genServiceUrl("cas", "CatalogingSOAP"))
|
|
e.AddKVNode("NusURL", genServiceUrl("nus", "NetUpdateSOAP"))
|
|
}
|