mirror of
https://github.com/mempool/mempool.git
synced 2025-04-07 19:38:32 +02:00
parent
774893f2fc
commit
a07a4de255
@ -22,5 +22,7 @@
|
||||
"BISQ_MARKETS_DATA_PATH": "/bisq/seednode-data/btc_mainnet/db",
|
||||
"SSL": false,
|
||||
"SSL_CERT_FILE_PATH": "/etc/letsencrypt/live/mysite/fullchain.pem",
|
||||
"SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem"
|
||||
"SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem",
|
||||
"BTCPAY_URL": "",
|
||||
"BTCPAY_AUTH": ""
|
||||
}
|
||||
|
125
backend/src/api/donations.ts
Normal file
125
backend/src/api/donations.ts
Normal file
@ -0,0 +1,125 @@
|
||||
const config = require('../../mempool-config.json');
|
||||
import * as request from 'request';
|
||||
import { DB } from '../database';
|
||||
|
||||
class Donations {
|
||||
private notifyDonationStatusCallback: ((invoiceId: string) => void) | undefined;
|
||||
private options = {
|
||||
baseUrl: config.BTCPAY_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': config.BTCPAY_AUTH,
|
||||
},
|
||||
};
|
||||
|
||||
constructor() { }
|
||||
|
||||
setNotfyDonationStatusCallback(fn: any) {
|
||||
this.notifyDonationStatusCallback = fn;
|
||||
}
|
||||
|
||||
createRequest(amount: number, orderId: string): Promise<any> {
|
||||
const postData = {
|
||||
'price': amount,
|
||||
'orderId': orderId,
|
||||
'currency': 'BTC',
|
||||
'itemDesc': 'Sponsor mempool.space',
|
||||
'notificationUrl': 'https://mempool.space/api/v1/donations-webhook',
|
||||
'redirectURL': 'https://mempool.space/about'
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
request.post({
|
||||
uri: '/invoices',
|
||||
json: postData,
|
||||
...this.options,
|
||||
}, (err, res, body) => {
|
||||
if (err) { return reject(err); }
|
||||
const formattedBody = {
|
||||
id: body.data.id,
|
||||
amount: parseFloat(body.data.btcPrice),
|
||||
address: body.data.bitcoinAddress,
|
||||
};
|
||||
resolve(formattedBody);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async $handleWebhookRequest(data: any) {
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
const response = await this.getStatus(data.id);
|
||||
if (response.status === 'complete') {
|
||||
if (this.notifyDonationStatusCallback) {
|
||||
this.notifyDonationStatusCallback(data.id);
|
||||
}
|
||||
|
||||
let imageUrl = '';
|
||||
if (response.orderId !== '') {
|
||||
try {
|
||||
imageUrl = await this.$getTwitterImageUrl(response.orderId);
|
||||
} catch (e) {
|
||||
console.log('Error fetching twitter image from Hive', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.$addDonationToDatabase(response, imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private getStatus(id: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.get({
|
||||
uri: '/invoices/' + id,
|
||||
json: true,
|
||||
...this.options,
|
||||
}, (err, res, body) => {
|
||||
if (err) { return reject(err); }
|
||||
resolve(body.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async $getDonationsFromDatabase() {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT handle, imageUrl FROM donations WHERE handle != ''`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.log('$getDonationsFromDatabase() error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $addDonationToDatabase(response: any, imageUrl: string): Promise<void> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `INSERT INTO donations(added, amount, handle, order_id, imageUrl) VALUES (NOW(), ?, ?, ?, ?)`;
|
||||
const params: (string | number)[] = [
|
||||
response.btcPaid,
|
||||
response.orderId,
|
||||
response.id,
|
||||
imageUrl,
|
||||
];
|
||||
const [result]: any = await connection.query(query, params);
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
console.log('$addDonationToDatabase() error', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getTwitterImageUrl(handle: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.get({
|
||||
uri: `https://api.hive.one/v1/influencers/screen_name/${handle}/?format=json`,
|
||||
json: true,
|
||||
}, (err, res, body) => {
|
||||
if (err) { return reject(err); }
|
||||
resolve(body.data.imageUrl);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Donations();
|
@ -99,6 +99,10 @@ class WebsocketHandler {
|
||||
response['pong'] = true;
|
||||
}
|
||||
|
||||
if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) {
|
||||
client['track-donation'] = parsedMessage['track-donation'];
|
||||
}
|
||||
|
||||
if (Object.keys(response).length) {
|
||||
client.send(JSON.stringify(response));
|
||||
}
|
||||
@ -109,6 +113,21 @@ class WebsocketHandler {
|
||||
});
|
||||
}
|
||||
|
||||
handleNewDonation(id: string) {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (client['track-donation'] === id) {
|
||||
client.send(JSON.stringify({ donationConfirmed: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleNewStatistic(stats: OptimizedStatistic) {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
|
@ -18,6 +18,7 @@ import websocketHandler from './api/websocket-handler';
|
||||
import fiatConversion from './api/fiat-conversion';
|
||||
import bisq from './api/bisq/bisq';
|
||||
import bisqMarkets from './api/bisq/markets';
|
||||
import donations from './api/donations';
|
||||
|
||||
class Server {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
@ -62,7 +63,9 @@ class Server {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
})
|
||||
.use(compression());
|
||||
.use(compression())
|
||||
.use(express.urlencoded({ extended: true }))
|
||||
.use(express.json());
|
||||
|
||||
if (config.SSL === true) {
|
||||
const credentials = {
|
||||
@ -122,6 +125,7 @@ class Server {
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||
donations.setNotfyDonationStatusCallback(websocketHandler.handleNewDonation.bind(websocketHandler));
|
||||
}
|
||||
|
||||
setUpHttpApiRoutes() {
|
||||
@ -163,6 +167,14 @@ class Server {
|
||||
.get(config.API_ENDPOINT + 'bisq/markets/volumes', routes.getBisqMarketVolumes.bind(routes))
|
||||
;
|
||||
}
|
||||
|
||||
if (config.BTCPAY_URL) {
|
||||
this.app
|
||||
.get(config.API_ENDPOINT + 'donations', routes.getDonations.bind(routes))
|
||||
.post(config.API_ENDPOINT + 'donations', routes.createDonationRequest.bind(routes))
|
||||
.post(config.API_ENDPOINT + 'donations-webhook', routes.donationWebhook.bind(routes))
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import bisq from './api/bisq/bisq';
|
||||
import bisqMarket from './api/bisq/markets-api';
|
||||
import { RequiredSpec } from './interfaces';
|
||||
import { MarketsApiError } from './api/bisq/interfaces';
|
||||
import donations from './api/donations';
|
||||
|
||||
class Routes {
|
||||
private cache = {};
|
||||
@ -98,6 +99,55 @@ class Routes {
|
||||
res.json(backendInfo.getBackendInfo());
|
||||
}
|
||||
|
||||
public async createDonationRequest(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'amount': {
|
||||
required: true,
|
||||
types: ['@float']
|
||||
},
|
||||
'orderId': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
}
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.body, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).send(p.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.amount < 0.01) {
|
||||
res.status(400).send('Amount needs to be at least 0.01');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await donations.createRequest(p.amount, p.orderId);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async getDonations(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await donations.$getDonationsFromDatabase();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async donationWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
donations.$handleWebhookRequest(req.body);
|
||||
res.end();
|
||||
} catch (e) {
|
||||
res.status(500).send(e);
|
||||
}
|
||||
}
|
||||
|
||||
public getBisqStats(req: Request, res: Response) {
|
||||
const result = bisq.getStats();
|
||||
res.json(result);
|
||||
@ -173,7 +223,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -195,7 +245,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -254,7 +304,7 @@ class Routes {
|
||||
}
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -281,7 +331,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -323,7 +373,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -365,7 +415,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -387,7 +437,7 @@ class Routes {
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req, constraints);
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
@ -401,15 +451,15 @@ class Routes {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRequestParameters(req: Request, params: RequiredSpec): { [name: string]: any; } {
|
||||
private parseRequestParameters(requestParams: object, params: RequiredSpec): { [name: string]: any; } {
|
||||
const final = {};
|
||||
for (const i in params) {
|
||||
if (params.hasOwnProperty(i)) {
|
||||
if (params[i].required && !req.query[i]) {
|
||||
if (params[i].required && requestParams[i] === undefined) {
|
||||
return { error: i + ' parameter missing'};
|
||||
}
|
||||
if (typeof req.query[i] === 'string') {
|
||||
const str = (req.query[i] || '').toString().toLowerCase();
|
||||
if (typeof requestParams[i] === 'string') {
|
||||
const str = (requestParams[i] || '').toString().toLowerCase();
|
||||
if (params[i].types.indexOf('@number') > -1) {
|
||||
const number = parseInt((str).toString(), 10);
|
||||
final[i] = number;
|
||||
@ -422,6 +472,8 @@ class Routes {
|
||||
} else {
|
||||
return { error: i + ' parameter invalid'};
|
||||
}
|
||||
} else if (typeof requestParams[i] === 'number') {
|
||||
final[i] = requestParams[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,49 @@
|
||||
|
||||
<br><br>
|
||||
|
||||
<iframe src='https://btcpay.wiz.biz/apps/4XrJdGCE2a8FMEkRd52YwoiwzJqP/pos' style='height: 600px; width: 400px; border: 0;'></iframe>
|
||||
<h2>❤️ Sponsors</h2>
|
||||
|
||||
<div *ngFor="let sponsor of sponsors$ | async; let i = index" (click)="openTwitterProfile(sponsor.handle)" class="profile_photo d-inline-block" [class.ml-3]="i > 0" [ngStyle]="{'background-image': 'url(' + sponsor.imageUrl + ')'}" [title]="sponsor.handle"></div>
|
||||
<br><br>
|
||||
|
||||
<button type="button" class="btn btn-primary" (click)="donationStatus = 2" [hidden]="donationStatus !== 1">Become a sponsor</button>
|
||||
|
||||
<div style="max-width: 300px;" class="mx-auto" [hidden]="donationStatus !== 2">
|
||||
<form [formGroup]="donationForm" (submit)="submitDonation()" class="form">
|
||||
<div class="input-group mb-2">
|
||||
<div class="input-group-prepend" style="width: 42px;">
|
||||
<span class="input-group-text">₿</span>
|
||||
</div>
|
||||
<input formControlName="amount" class="form-control" type="number" min="0.0001" step="1E-03">
|
||||
</div>
|
||||
<div class="input-group mb-4">
|
||||
<div class="input-group-prepend" style="width: 42px;">
|
||||
<span class="input-group-text">@</span>
|
||||
</div>
|
||||
<input formControlName="handle" class="form-control" type="text" placeholder="Twitter handle (Optional)">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button class="btn btn-primary mx-auto" type="submit">Request invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div *ngIf="donationStatus === 3" class="text-center">
|
||||
<div class="qr-wrapper mt-4 mb-2">
|
||||
<app-qrcode [data]="donationObj.address + '?amount=' + donationObj.amount"></app-qrcode>
|
||||
</div>
|
||||
<br>
|
||||
<p style="font-size: 10px;">{{ donationObj.address }}</p>
|
||||
<p>Waiting for transaction... </p>
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="donationStatus === 4" class="text-center">
|
||||
<h2>Donation confirmed!<br>Thank you!</h2>
|
||||
<p>If you specified a Twitter handle, the profile photo should now be visible on this page when you reload.</p>
|
||||
</div>
|
||||
|
||||
<br><br>
|
||||
|
||||
<h2>GitHub</h2>
|
||||
|
||||
|
@ -14,3 +14,17 @@ tr {
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
background-color: #FFF;
|
||||
padding: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.profile_photo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-size: 100%, 100%;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -3,22 +3,29 @@ import { WebsocketService } from '../../services/websocket.service';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ApiService } from 'src/app/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
templateUrl: './about.component.html',
|
||||
styleUrls: ['./about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
active = 1;
|
||||
hostname = document.location.hostname;
|
||||
gitCommit$: Observable<string>;
|
||||
donationForm: FormGroup;
|
||||
donationStatus = 1;
|
||||
sponsors$: Observable<any>;
|
||||
donationObj: any;
|
||||
|
||||
constructor(
|
||||
private websocketService: WebsocketService,
|
||||
private seoService: SeoService,
|
||||
private stateService: StateService,
|
||||
private formBuilder: FormBuilder,
|
||||
private apiService: ApiService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
@ -31,5 +38,32 @@ export class AboutComponent implements OnInit {
|
||||
if (document.location.port !== '') {
|
||||
this.hostname = this.hostname + ':' + document.location.port;
|
||||
}
|
||||
|
||||
this.donationForm = this.formBuilder.group({
|
||||
amount: [0.001],
|
||||
handle: [''],
|
||||
});
|
||||
|
||||
this.sponsors$ = this.apiService.getDonation$();
|
||||
this.stateService.donationConfirmed$.subscribe(() => this.donationStatus = 4);
|
||||
}
|
||||
|
||||
submitDonation() {
|
||||
if (this.donationForm.invalid) {
|
||||
return;
|
||||
}
|
||||
this.apiService.requestDonation$(
|
||||
this.donationForm.get('amount').value,
|
||||
this.donationForm.get('handle').value
|
||||
)
|
||||
.subscribe((response) => {
|
||||
this.websocketService.trackDonation(response.id);
|
||||
this.donationObj = response;
|
||||
this.donationStatus = 3;
|
||||
});
|
||||
}
|
||||
|
||||
openTwitterProfile(handle: string) {
|
||||
window.open('https://twitter.com/' + handle, '_blank');
|
||||
}
|
||||
}
|
||||
|
@ -14,10 +14,12 @@ export interface WebsocketResponse {
|
||||
tx?: Transaction;
|
||||
rbfTransaction?: Transaction;
|
||||
transactions?: TransactionStripped[];
|
||||
donationConfirmed?: boolean;
|
||||
'track-tx'?: string;
|
||||
'track-address'?: string;
|
||||
'track-asset'?: string;
|
||||
'watch-mempool'?: boolean;
|
||||
'track-donation'?: string;
|
||||
}
|
||||
|
||||
export interface MempoolBlock {
|
||||
|
@ -61,4 +61,16 @@ export class ApiService {
|
||||
});
|
||||
return this.httpClient.get<number[]>(this.apiBaseUrl + '/transaction-times', { params });
|
||||
}
|
||||
|
||||
requestDonation$(amount: number, orderId: string): Observable<any> {
|
||||
const params = {
|
||||
amount: amount,
|
||||
orderId: orderId,
|
||||
};
|
||||
return this.httpClient.post<any>(this.apiBaseUrl + '/donations', params);
|
||||
}
|
||||
|
||||
getDonation$(): Observable<any[]> {
|
||||
return this.httpClient.get<any[]>(this.apiBaseUrl + '/donations');
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,7 @@ export class StateService {
|
||||
vbytesPerSecond$ = new ReplaySubject<number>(1);
|
||||
lastDifficultyAdjustment$ = new ReplaySubject<number>(1);
|
||||
gitCommit$ = new ReplaySubject<string>(1);
|
||||
donationConfirmed$ = new Subject();
|
||||
|
||||
live2Chart$ = new Subject<OptimizedMempoolStats>();
|
||||
|
||||
|
@ -155,6 +155,10 @@ export class WebsocketService {
|
||||
this.stateService.gitCommit$.next(response['git-commit']);
|
||||
}
|
||||
|
||||
if (response.donationConfirmed) {
|
||||
this.stateService.donationConfirmed$.next(true);
|
||||
}
|
||||
|
||||
if (this.goneOffline === true) {
|
||||
this.goneOffline = false;
|
||||
if (this.lastWant) {
|
||||
@ -189,6 +193,10 @@ export class WebsocketService {
|
||||
this.isTrackingTx = true;
|
||||
}
|
||||
|
||||
trackDonation(id: string) {
|
||||
this.websocketSubject.next({ 'track-donation': id });
|
||||
}
|
||||
|
||||
stopTrackingTransaction() {
|
||||
if (!this.isTrackingTx) {
|
||||
return;
|
||||
|
@ -21,6 +21,11 @@ $pagination-hover-bg: #12131e;
|
||||
$pagination-hover-border-color: #1d1f31;
|
||||
$pagination-disabled-bg: #1d1f31;
|
||||
|
||||
.input-group-text {
|
||||
background-color: #1c2031 !important;
|
||||
border: 1px solid #20263e !important;
|
||||
}
|
||||
|
||||
$link-color: #1bd8f4;
|
||||
$link-decoration: none !default;
|
||||
$link-hover-color: darken($link-color, 15%) !default;
|
||||
|
@ -84,3 +84,19 @@ ALTER TABLE `transactions`
|
||||
|
||||
ALTER TABLE `statistics`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
|
||||
CREATE TABLE `donations` (
|
||||
`id` int(11) NOT NULL,
|
||||
`added` datetime NOT NULL,
|
||||
`amount` float NOT NULL,
|
||||
`handle` varchar(250) NOT NULL,
|
||||
`order_id` varchar(25) NOT NULL,
|
||||
`imageUrl` varchar(250) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
ALTER TABLE `donations`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
ALTER TABLE `donations`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
Loading…
x
Reference in New Issue
Block a user